File size: 9,370 Bytes
5ba153f
 
 
e5b3b38
d5b36d8
e5b3b38
 
 
 
 
 
5ba153f
 
 
e5b3b38
5ba153f
 
b8a4efc
 
f29091b
bc2dd3b
 
5ba153f
bc2dd3b
 
 
 
 
 
 
 
5ba153f
 
 
 
782e2c0
056ae7d
812867d
5ba153f
 
 
 
 
d5b36d8
5ba153f
 
 
812867d
5ba153f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
812867d
5ba153f
 
 
812867d
 
 
 
 
 
5ba153f
 
 
e5b3b38
5ba153f
d5b36d8
5ba153f
17e0d1a
bc2dd3b
812867d
e5b3b38
 
 
 
 
5ba153f
 
812867d
5ba153f
 
 
812867d
5ba153f
 
812867d
5ba153f
 
bc2dd3b
 
 
5ba153f
 
e5b3b38
5ba153f
 
bc2dd3b
 
 
 
 
 
812867d
bc2dd3b
5ba153f
e5b3b38
 
 
 
 
 
 
 
 
 
bc2dd3b
 
5ba153f
e5b3b38
5ba153f
 
e5b3b38
 
 
 
 
 
bc2dd3b
e5b3b38
bc2dd3b
 
 
 
e5b3b38
 
bc2dd3b
5ba153f
e5b3b38
 
 
 
 
 
 
 
 
 
 
 
 
bc2dd3b
91723e9
e5b3b38
5ba153f
 
 
e5b3b38
5ba153f
a31792e
 
 
 
 
637f943
 
 
e5b3b38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
637f943
 
f29091b
17e0d1a
d5b36d8
cdb0a78
e5b3b38
d5b36d8
 
e5b3b38
 
5ba153f
e5b3b38
cdb0a78
e5b3b38
 
 
 
 
 
5ba153f
b8a4efc
11fb5fe
 
b8a4efc
e5b3b38
008887c
 
e5b3b38
008887c
b8a4efc
5ba153f
a3bae88
 
4e45d4a
e5b3b38
5ba153f
 
e5b3b38
 
 
5ba153f
 
e5b3b38
5ba153f
 
d5b36d8
e5b3b38
d5b36d8
e5b3b38
d5b36d8
 
 
 
 
 
654a79d
d65bc48
e5b3b38
11fb5fe
812867d
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
"""
======================================================
📘 金融客服小智(Fintech Assistant)
版本:v3.4 (📱自動縮放優化版)
更新重點:
1. LLM 三次重試機制(防止 API 錯誤中斷)
2. 整合記憶進 prompt(上下文連貫對話)
3. 安全向量搜尋(避免空 collection 錯誤)
4. lambda 修正(避免共享同一 history)
5. 顯示自動分類提示(可見知識來源)
6. 📱 新增手機縮放與字體比例自適應
======================================================
"""

import os, re, base64, time
import chromadb
import gradio as gr
from langchain_core.documents import Document
from langchain_chroma import Chroma
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_google_genai import ChatGoogleGenerativeAI

# === 記憶模組相容多版本 ===
try:
    from langchain_memory import ConversationBufferMemory
except ImportError:
    try:
        from langchain.memory import ConversationBufferMemory
    except ImportError:
        from langchain_community.memory import ConversationBufferMemory


# =============================================
# 1️⃣ Embedding 與基礎設定
# =============================================
embedding = HuggingFaceEmbeddings(model_name="BAAI/bge-small-zh-v1.5")

BASE_DIR = os.getcwd()
QA_PATH = os.path.join(BASE_DIR, "QA_v2.txt")
LOGO_PATH = os.path.join(BASE_DIR, "mega.png")

API_KEY = os.getenv("GOOGLE_API_KEY")
if not API_KEY:
    print("⚠️ 尚未設定 GOOGLE_API_KEY,系統將以模擬模式運行。")


# =============================================
# 2️⃣ QA 載入與分類
# =============================================
def load_qa_documents(path: str):
    with open(path, "r", encoding="utf-8") as f:
        text = f.read()
    pattern = r"(Q[::].*?A[::].*?)(?=Q[::]|$)"
    qas = re.findall(pattern, text, flags=re.S)

    categories = {"證券": [], "期貨": [], "複委託": []}
    for qa in qas:
        doc = Document(page_content=qa.strip())
        if "證券" in qa:
            categories["證券"].append(doc)
        elif "期貨" in qa:
            categories["期貨"].append(doc)
        elif "複委託" in qa:
            categories["複委託"].append(doc)
        else:
            categories["證券"].append(doc)
    return categories


if os.path.exists(QA_PATH):
    qa_docs = load_qa_documents(QA_PATH)
    print("✅ 已載入 QA 檔案,共分為:", {k: len(v) for k, v in qa_docs.items()})
else:
    print("⚠️ 未找到 QA_v2.txt,啟用空白知識庫模式。")
    qa_docs = {"證券": [], "期貨": [], "複委託": []}


# =============================================
# 3️⃣ 向量資料庫初始化(含安全檢查)
# =============================================
client = chromadb.Client()
collection_map = {"證券": "stocks", "期貨": "futures", "複委託": "overseas"}
vectordbs = {}
for cat, docs in qa_docs.items():
    vectordb = Chroma(client=client, collection_name=collection_map[cat], embedding_function=embedding)
    try:
        count = vectordb._collection.count() if hasattr(vectordb._collection, "count") else len(vectordb.get()["ids"])
    except Exception:
        count = 0
    if count == 0 and docs:
        vectordb.add_documents(docs)
    vectordbs[cat] = vectordb
print("✅ 向量資料庫初始化完成。")


# =============================================
# 4️⃣ 初始化 LLM 與記憶體
# =============================================
if API_KEY:
    llm = ChatGoogleGenerativeAI(model="gemini-2.0-flash", google_api_key=API_KEY)
else:
    llm = None  # 模擬模式

memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True)


# =============================================
# 5️⃣ 對話邏輯(改進版)
# =============================================
def auto_detect_category(text: str):
    if any(k in text for k in ["股票", "證券", "開戶", "下單", "交割"]):
        return "證券"
    elif any(k in text for k in ["期貨", "選擇權", "保證金"]):
        return "期貨"
    elif any(k in text for k in ["複委託", "海外", "美股", "港股"]):
        return "複委託"
    return "證券"


def safe_similarity_search(vectordb, query, k=2):
    """防止空 collection 錯誤"""
    try:
        results = vectordb.similarity_search(query, k=k)
    except Exception as e:
        print(f"⚠️ 向量搜尋錯誤:{e}")
        results = []
    return results


def chat_fn(message, history):
    category = auto_detect_category(message)
    vectordb = vectordbs[category]
    docs = safe_similarity_search(vectordb, message, k=2)
    context = "\n\n".join(d.page_content for d in docs) if docs else "查無相關資料"

    # ✅ 整合記憶體歷史紀錄
    history_data = memory.load_memory_variables({}).get("chat_history", [])
    history_text = "\n".join(
        [f"{m['role']}: {m['content']}" for m in history_data if isinstance(m, dict)]
    )

    prompt = f"""
你是一位金融客服人員,請根據以下QA知識回答。
---
{context}
---
使用者問題:{message}
過往對話:
{history_text}
    """

    # ✅ LLM 重試機制(3次)
    if llm:
        for attempt in range(3):
            try:
                response = llm.invoke(prompt)
                reply = getattr(response, "content", None) or getattr(response, "text", "⚠️ 無回覆")
                break
            except Exception as e:
                print(f"⚠️ 第 {attempt+1} 次 LLM 錯誤:{e}")
                time.sleep(2)
                reply = "⚠️ 系統忙碌中,請稍後再試。"
    else:
        reply = "(模擬模式)這是示範回覆,請確認是否已設定 GOOGLE_API_KEY。"

    memory.save_context({"input": message}, {"output": reply})
    return f"📂 類別:{category}\n\n{reply}"


# =============================================
# 6️⃣ Gradio 介面(含手機縮放CSS)
# =============================================
logo_base64 = ""
if os.path.exists(LOGO_PATH):
    with open(LOGO_PATH, "rb") as f:
        logo_base64 = base64.b64encode(f.read()).decode("utf-8")

with gr.Blocks(
    theme="soft",
    css="""
/* === 📱 全域縮放設定 === */
@media (max-width: 768px) {
    html, body {
        zoom: 0.85;
        -moz-transform: scale(0.85);
        -moz-transform-origin: top left;
    }
}

/* === Logo 與標題自適應 === */
#logo-top img { width: 120px; height: auto; }
@media (max-width: 768px) {
    #logo-top img { width: 80px; }
    h1 { font-size: 20px !important; }
}

/* === 輸入列縮窄設定 === */
@media (max-width: 768px) {
    .gradio-container { padding: 6px; }
    #chat-row { flex-direction: row !important; gap: 4px !important; }
    #chat-row textarea { font-size: 14px !important; height: 42px !important; }
    #send-btn { font-size: 14px !important; height: 42px !important; }
}
"""
) as demo:
    if logo_base64:
        gr.HTML(f"<div id='logo-top'><img src='data:image/png;base64,{logo_base64}'></div>")

    gr.HTML("""
    <h1 style='text-align:center;'>👨‍💼 我是小智 您的金融好幫手 🫰</h1>
    <p style='text-align:center;color:gray;'>Powered by Gemini & LangChain</p>
    """)

    with gr.Row():
        with gr.Column(scale=4):
            chatbot = gr.Chatbot(label="💬 對話紀錄", type="messages", height=500)
            user_input = gr.Textbox(
                placeholder="請輸入您的問題,或點選下列「常見問題」...",
                show_label=False,
                lines=1,
                max_lines=3,
                elem_id="chat-row"
            )
            send_btn = gr.Button("送出", variant="primary", elem_id="send-btn")

            def handle_input(message, history):
                if not message.strip():
                    return history, gr.update(value="")
                reply = chat_fn(message, history)
                history = history or []
                history += [
                    {"role": "user", "content": message},
                    {"role": "assistant", "content": reply},
                ]
                return history, gr.update(value="")

            user_input.submit(handle_input, [user_input, chatbot], [chatbot, user_input])
            send_btn.click(handle_input, [user_input, chatbot], [chatbot, user_input])

        with gr.Column(scale=1):
            gr.Markdown("### 🔍 常見問題")
            examples = [
                "密碼忘記了怎麼辦?",
                "下單憑證怎麼申請?",
                "法人開證劵戶要準備什麼?",
                "期貨交易保證金是什麼?",
                "美股交易時間?",
                "美股可以定期定額嗎?",
            ]
            for q in examples:
                gr.Button(q).click(
                    fn=lambda q=q: handle_input(q, []),
                    inputs=[],
                    outputs=[chatbot, user_input],
                )

            def clear_all():
                memory.clear()
                return [], gr.update(value="")
            gr.Markdown("---")
            gr.Button("🧹 整理畫面").click(clear_all, outputs=[chatbot, user_input])

    gr.HTML("<div id='footer' style='text-align:center;color:#aaa;'>© Fintech Assistant — 僅業務使用,非官方授權</div>")

demo.launch()