tktm8 commited on
Commit
85e01dd
·
verified ·
1 Parent(s): cdf7866

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +546 -0
app.py ADDED
@@ -0,0 +1,546 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ EmpathemeBot - Hugging Face Spaces用統合版Streamlitアプリ(セキュア版v2)
3
+ APIキーをセッション単位で管理し、ユーザー間で共有されないようにする
4
+ 「新しいチャット」ボタンでAPIキーを維持する改良版
5
+ """
6
+
7
+ import html
8
+ import logging
9
+ import re
10
+ import time
11
+ import uuid
12
+ from datetime import datetime
13
+ from typing import List, Dict, Optional
14
+ from pathlib import Path
15
+ import sys
16
+
17
+ import streamlit as st
18
+ from dotenv import load_dotenv
19
+ import os
20
+
21
+ # 環境変数の読み込み
22
+ load_dotenv()
23
+
24
+ # srcディレクトリをパスに追加
25
+ sys.path.append(str(Path(__file__).parent))
26
+
27
+ # QAChainをインポート
28
+ from src.qa.chain import QAChain
29
+
30
+ # ロギング設定
31
+ logging.basicConfig(
32
+ level=logging.INFO,
33
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
34
+ )
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # 設定
38
+ st.set_page_config(
39
+ page_title="EmpathemeBot QA System",
40
+ page_icon="",
41
+ layout="wide",
42
+ initial_sidebar_state="collapsed",
43
+ menu_items={}
44
+ )
45
+
46
+
47
+
48
+ class EmpathemeBotUI:
49
+ """Hugging Face Spaces用セキュア版EmpathemeBot UIクラス"""
50
+
51
+ def __init__(self):
52
+ # セッション状態の初期化(APIキーは保存しない)
53
+ if 'session_id' not in st.session_state:
54
+ st.session_state.session_id = str(uuid.uuid4())
55
+ if 'messages' not in st.session_state:
56
+ st.session_state.messages = []
57
+ if 'qa_chain' not in st.session_state:
58
+ st.session_state.qa_chain = None
59
+ if 'last_activity' not in st.session_state:
60
+ st.session_state.last_activity = datetime.now()
61
+ if 'vector_store_initialized' not in st.session_state:
62
+ st.session_state.vector_store_initialized = False
63
+ # APIキー関連の状態(セッション単位)
64
+ if 'current_api_key' not in st.session_state:
65
+ st.session_state.current_api_key = "" # 現在のセッションのAPIキー
66
+
67
+ def initialize_qa_chain(self, api_key: str) -> bool:
68
+ """
69
+ QAChainを初期化(APIキーを環境変数に設定してQAChainを使用)
70
+
71
+ Args:
72
+ api_key: OpenAI APIキー
73
+
74
+ Returns:
75
+ 初期化成功の場合True
76
+ """
77
+ try:
78
+ # APIキーを環境変数に設定
79
+ os.environ["OPENAI_API_KEY"] = api_key
80
+
81
+ # ベクトルストアのパスを確認
82
+ vector_store_path = Path("data/vector_store")
83
+
84
+ # QAChainの初期化(src/qa/chain.pyのQAChainを使用)
85
+ logger.info("QAChainを初期化中...")
86
+
87
+ # 元のQAChainを使用(デフォルトでgpt-5-nanoを使用)
88
+ st.session_state.qa_chain = QAChain(
89
+ persist_dir=str(vector_store_path),
90
+ verbose=False,
91
+ max_history_turns=10,
92
+ max_history_chars=10000
93
+ # llm_modelは指定しない(デフォルトの"gpt-5-nano"を使用)
94
+ )
95
+
96
+ st.session_state.vector_store_initialized = True
97
+ st.session_state.current_api_key = api_key # 現在のセッションのAPIキーを記録
98
+ logger.info("QAChain初期化完了")
99
+ return True
100
+
101
+ except Exception as e:
102
+ logger.error(f"QAChain初期化エラー: {e}")
103
+ st.error(f"初期化エラー: {str(e)}")
104
+ return False
105
+
106
+ def ask_question(self, question: str) -> Optional[Dict]:
107
+ """
108
+ 質問を処理して回答を取得
109
+
110
+ Args:
111
+ question: ユーザーの質問
112
+
113
+ Returns:
114
+ 回答データ
115
+ """
116
+ try:
117
+ if st.session_state.qa_chain is None:
118
+ st.error("システムが初期化されていません。APIキーを入力してください。")
119
+ return None
120
+
121
+ # 質問処理
122
+ logger.info(f"質問処理開始: {question[:100]}...")
123
+ answer, source_docs = st.session_state.qa_chain.ask_with_history(question)
124
+
125
+ # ソースURLを抽出(重複除去)
126
+ source_urls = []
127
+ for doc in source_docs:
128
+ if hasattr(doc, 'metadata'):
129
+ url = doc.metadata.get('source_url', '')
130
+ if url and url not in source_urls:
131
+ source_urls.append(url)
132
+
133
+ result = {
134
+ "answer": answer,
135
+ "source_count": len(source_docs),
136
+ "source_urls": source_urls
137
+ }
138
+
139
+ logger.info(f"回答生成成功: {len(source_docs)}件のソース参照")
140
+ return result
141
+
142
+ except Exception as e:
143
+ logger.error(f"エラー発生: {e}")
144
+ st.error(f"予期しないエ���ーが発生しました: {str(e)}")
145
+ return None
146
+
147
+ def clear_history(self):
148
+ """会話履歴をクリア"""
149
+ try:
150
+ if st.session_state.qa_chain:
151
+ st.session_state.qa_chain.clear_history()
152
+ st.session_state.messages = []
153
+ st.success("会話履歴をクリアしました")
154
+ logger.info("履歴クリア成功")
155
+ except Exception as e:
156
+ logger.error(f"履歴クリアエラー: {e}")
157
+ st.error("エラーが発生しました")
158
+
159
+ def create_new_session(self):
160
+ """新しいセッションIDを生成(APIキーは維持)"""
161
+ st.session_state.session_id = str(uuid.uuid4())
162
+ st.session_state.messages = [] # メッセージのみクリア
163
+
164
+ # APIキーが設定済みの場合は維持
165
+ if st.session_state.current_api_key:
166
+ # 既存のQAChainの会話履歴のみクリア
167
+ if st.session_state.qa_chain:
168
+ st.session_state.qa_chain.clear_history()
169
+ # APIキーとvector_store_initializedは維持
170
+ logger.info(f"新しいチャット開始(APIキー維持): {st.session_state.session_id}")
171
+ else:
172
+ # APIキーが未設定の場合のみリセット
173
+ st.session_state.qa_chain = None
174
+ st.session_state.vector_store_initialized = False
175
+ logger.info(f"新しいセッション作成: {st.session_state.session_id}")
176
+
177
+ def main():
178
+ """メイン関数"""
179
+
180
+ # カスタムCSS
181
+ st.markdown("""
182
+ <style>
183
+ /* メインコンテナのスタイル */
184
+ .main {
185
+ padding-top: 1rem;
186
+ max-width: 1000px;
187
+ margin: 0 auto;
188
+ }
189
+
190
+ .block-container {
191
+ padding: 1rem 2rem;
192
+ max-width: 100%;
193
+ }
194
+
195
+ /* チャット入力のスタイル */
196
+ .stChatInput {
197
+ border: none !important;
198
+ box-shadow: none !important;
199
+ background: transparent !important;
200
+ position: fixed;
201
+ bottom: 0;
202
+ padding-bottom: 1rem;
203
+ background: white !important;
204
+ z-index: 999;
205
+ }
206
+
207
+ /* チャット入力のテキストエリア */
208
+ .stChatInput textarea {
209
+ font-size: 14px;
210
+ border: 1px solid #E5E7EB !important;
211
+ border-radius: 8px !important;
212
+ padding: 0.6rem 1rem !important;
213
+ background: #FAFAFA !important;
214
+ transition: all 0.2s ease;
215
+ }
216
+
217
+ .stChatInput textarea:focus {
218
+ background: white !important;
219
+ border-color: #4F46E5 !important;
220
+ outline: none !important;
221
+ box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1) !important;
222
+ }
223
+
224
+ /* ボタンのスタイル */
225
+ .stButton > button {
226
+ background: #4F46E5;
227
+ color: white;
228
+ border: none;
229
+ border-radius: 6px;
230
+ padding: 0.5rem 1rem;
231
+ font-weight: 500;
232
+ font-size: 13px;
233
+ transition: all 0.15s ease;
234
+ }
235
+
236
+ .stButton > button:hover {
237
+ background: #4338CA;
238
+ }
239
+
240
+ /* タイトルのスタイル */
241
+ h1 {
242
+ color: #111827;
243
+ font-weight: 600;
244
+ text-align: center;
245
+ font-size: 1.75rem;
246
+ margin-bottom: 0.5rem;
247
+ }
248
+
249
+ /* サイドバーのスタイル */
250
+ section[data-testid="stSidebar"] {
251
+ background: #FAFAFB;
252
+ }
253
+
254
+ /* 吹き出し内のコンテンツスタイル */
255
+ .bubble-content {
256
+ font-family: inherit;
257
+ font-size: inherit;
258
+ white-space: pre-wrap;
259
+ word-wrap: break-word;
260
+ margin: 0;
261
+ padding: 0;
262
+ color: inherit;
263
+ }
264
+ </style>
265
+ """, unsafe_allow_html=True)
266
+
267
+ # UIインスタンス作成
268
+ bot = EmpathemeBotUI()
269
+
270
+ # サイドバー設定
271
+ with st.sidebar:
272
+ st.markdown("## 設定")
273
+
274
+ # APIキー入力欄(セッションごとに必要)
275
+ st.markdown("### OpenAI API キー")
276
+
277
+ # 現在のAPIキー状態を表示
278
+ if st.session_state.current_api_key:
279
+ st.success("✅ APIキー設定済み")
280
+ # APIキーの一部を表示(セキュリティのため)
281
+ masked_key = st.session_state.current_api_key[:7] + "..." + st.session_state.current_api_key[-4:]
282
+ st.caption(f"現在のキー: {masked_key}")
283
+ else:
284
+ st.info("⚠️ APIキーを入力してください")
285
+
286
+ # APIキー入力フォーム
287
+ with st.form("api_key_form"):
288
+ api_key_input = st.text_input(
289
+ "APIキーを入力",
290
+ type="password",
291
+ placeholder="sk-...",
292
+ help="このセッション専用のAPIキーです。ブラウザを閉じると消去されます。",
293
+ key="api_key_input_field"
294
+ )
295
+
296
+ submit_button = st.form_submit_button("APIキーを設定", use_container_width=True)
297
+
298
+ if submit_button and api_key_input:
299
+ if api_key_input.startswith("sk-"):
300
+ # QAChainを初期化
301
+ if bot.initialize_qa_chain(api_key_input):
302
+ st.success("APIキーが設定されました")
303
+ st.rerun()
304
+ else:
305
+ st.error("初期化に失敗しました")
306
+ else:
307
+ st.error("有効なAPIキー(sk-で始まる)を入力してください")
308
+
309
+ st.markdown("---")
310
+
311
+ # コントロールボタン
312
+ st.markdown("### コントロール")
313
+ if st.button("新しいチャット", use_container_width=True):
314
+ bot.create_new_session()
315
+ st.rerun()
316
+
317
+ if st.button("履歴クリア", use_container_width=True):
318
+ bot.clear_history()
319
+ st.rerun()
320
+
321
+ st.markdown("---")
322
+
323
+ # ステータス
324
+ st.markdown("### ステータス")
325
+ if st.session_state.vector_store_initialized:
326
+ st.success("システム準備完了")
327
+ else:
328
+ st.info("システム待機中")
329
+
330
+ # セッション情報
331
+ st.markdown("---")
332
+ st.caption(f"セッションID: {st.session_state.session_id[:8]}...")
333
+ if st.session_state.current_api_key:
334
+ st.caption("APIキー設定済み(新しいチャットでも維持)")
335
+
336
+ # メインヘッダー
337
+ st.markdown(
338
+ """
339
+ <div style='text-align: center; margin-bottom: 2rem;'>
340
+ <h1 style='margin-bottom: 0.25rem;'>EmpathemeBot</h1>
341
+ <p style='color: #6B7280; font-size: 0.9rem;'>Potionベースの質問応答システム</p>
342
+ </div>
343
+ """,
344
+ unsafe_allow_html=True
345
+ )
346
+
347
+ # APIキー未入力時の警告メッセージ
348
+ if not st.session_state.current_api_key:
349
+ st.markdown(
350
+ """
351
+ <div style="background: #FEF3C7; border: 2px solid #F59E0B; border-radius: 12px; padding: 1.5rem; margin: 2rem 0;">
352
+ <h3 style="color: #92400E; margin-top: 0;">APIキーの入力が必要です</h3>
353
+ <p style="color: #78350F; margin-bottom: 1rem;">
354
+ EmpathemeBotを使用するには、OpenAI APIキーが必要です。
355
+ </p>
356
+ <ol style="color: #78350F; margin-left: 1.5rem;">
357
+ <li>左上の「>」ボタンをクリックしてサイドバーを開く</li>
358
+ <li>「OpenAI API キー」セクションにAPIキー(sk-...)を入力</li>
359
+ <li>「APIキーを設定」ボタンをクリック</li>
360
+ </ol>
361
+ <p style="color: #78350F; font-size: 0.9rem; margin-top: 1rem;">
362
+ APIキーは <a href="https://platform.openai.com/api-keys" target="__blank" style="color: #F59E0B;">OpenAIのダッシュボード</a> から取得できます。
363
+ </p>
364
+ <p style="color: #78350F; font-size: 0.85rem; margin-top: 0.5rem; font-style: italic;">
365
+ ※ APIキーは各ブラウザセッション専用です。他のユーザーと共有されません。
366
+ </p>
367
+ </div>
368
+ """,
369
+ unsafe_allow_html=True
370
+ )
371
+ st.stop()
372
+
373
+ # ウェルカムメッセージ(初回のみ)
374
+ if len(st.session_state.messages) == 0:
375
+ st.markdown(
376
+ """
377
+ <div style="text-align: center; padding: 3rem 0; color: #6B7280;">
378
+ <p style="font-size: 0.95rem;">こんにちは、KurageSan®だよ!何か英語学習に関して困っていることはありますか?</p>
379
+ </div>
380
+ """,
381
+ unsafe_allow_html=True
382
+ )
383
+
384
+ # チャット履歴の表示
385
+ for message in st.session_state.messages:
386
+ if message["role"] == "user":
387
+ # ユーザーメッセージ(右側)
388
+ st.markdown(
389
+ f"""
390
+ <div style="display: flex; justify-content: flex-end; margin: 1rem 0; padding-right: 1rem;">
391
+ <div style="background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%);
392
+ color: white;
393
+ padding: 0.75rem 1.25rem;
394
+ border-radius: 18px 18px 4px 18px;
395
+ max-width: 60%;
396
+ box-shadow: 0 2px 10px rgba(79, 70, 229, 0.2);
397
+ word-wrap: break-word;">
398
+ <pre class="bubble-content">{html.escape(message['content'])}</pre>
399
+ <div style="font-size: 0.7rem; opacity: 0.8; margin-top: 0.3rem; text-align: right;">
400
+ {message.get('timestamp', '')}
401
+ </div>
402
+ </div>
403
+ </div>
404
+ """,
405
+ unsafe_allow_html=True
406
+ )
407
+ else:
408
+ # アシスタントメッセージ(左側)
409
+ st.markdown(
410
+ f"""
411
+ <div style="display: flex; justify-content: flex-start; margin: 1rem 0; padding-left: 1rem;">
412
+ <div style="background: #F3F4F6;
413
+ color: #111827;
414
+ padding: 0.75rem 1.25rem;
415
+ border-radius: 18px 18px 18px 4px;
416
+ max-width: 60%;
417
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
418
+ word-wrap: break-word;">
419
+ <pre class="bubble-content">{html.escape(message['content'])}</pre>
420
+ <div style="font-size: 0.7rem; opacity: 0.6; margin-top: 0.3rem;">
421
+ {message.get('timestamp', '')}
422
+ </div>
423
+ </div>
424
+ </div>
425
+ """,
426
+ unsafe_allow_html=True
427
+ )
428
+
429
+ # チャット入力
430
+ if prompt := st.chat_input("質問を入力してください...", key="chat_input"):
431
+ # タイムスタンプを追加
432
+ timestamp = datetime.now().strftime("%H:%M")
433
+
434
+ # ユーザーメッセージを追加
435
+ st.session_state.messages.append({
436
+ "role": "user",
437
+ "content": prompt,
438
+ "timestamp": timestamp
439
+ })
440
+
441
+ # ユーザーメッセージを表示
442
+ st.markdown(
443
+ f"""
444
+ <div style="display: flex; justify-content: flex-end; margin: 1rem 0; padding-right: 1rem;">
445
+ <div style="background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%);
446
+ color: white;
447
+ padding: 0.75rem 1.25rem;
448
+ border-radius: 18px 18px 4px 18px;
449
+ max-width: 60%;
450
+ box-shadow: 0 2px 10px rgba(79, 70, 229, 0.2);
451
+ word-wrap: break-word;">
452
+ <pre class="bubble-content">{html.escape(prompt)}</pre>
453
+ <div style="font-size: 0.7rem; opacity: 0.8; margin-top: 0.3rem; text-align: right;">
454
+ {timestamp}
455
+ </div>
456
+ </div>
457
+ </div>
458
+ """,
459
+ unsafe_allow_html=True
460
+ )
461
+
462
+ # アシスタントの応答を生成
463
+ with st.spinner("考えています..."):
464
+ response_timestamp = datetime.now().strftime("%H:%M")
465
+ response_data = bot.ask_question(prompt)
466
+
467
+ if response_data:
468
+ answer = response_data["answer"]
469
+
470
+ # メッセージ履歴に追加
471
+ st.session_state.messages.append({
472
+ "role": "assistant",
473
+ "content": answer,
474
+ "timestamp": response_timestamp,
475
+ "metadata": {
476
+ "source_count": response_data.get("source_count", 0)
477
+ }
478
+ })
479
+
480
+ # アシスタントメッセージを表示
481
+ st.markdown(
482
+ f"""
483
+ <div style="display: flex; justify-content: flex-start; margin: 1rem 0; padding-left: 1rem;">
484
+ <div style="background: #F3F4F6;
485
+ color: #111827;
486
+ padding: 0.75rem 1.25rem;
487
+ border-radius: 18px 18px 18px 4px;
488
+ max-width: 60%;
489
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
490
+ word-wrap: break-word;">
491
+ <pre class="bubble-content">{html.escape(answer)}</pre>
492
+ <div style="font-size: 0.7rem; opacity: 0.6; margin-top: 0.3rem;">
493
+ {response_timestamp}
494
+ </div>
495
+ </div>
496
+ </div>
497
+ """,
498
+ unsafe_allow_html=True
499
+ )
500
+ else:
501
+ # エラーの場合
502
+ error_message = "申し訳ございません。回答の生成に失敗しました。もう一度お試しください。"
503
+
504
+ st.session_state.messages.append({
505
+ "role": "assistant",
506
+ "content": error_message,
507
+ "timestamp": response_timestamp
508
+ })
509
+
510
+ # エラーメッセージを表示
511
+ st.markdown(
512
+ f"""
513
+ <div style="display: flex; justify-content: flex-start; margin: 1rem 0; padding-left: 1rem;">
514
+ <div style="background: #F3F4F6;
515
+ color: #111827;
516
+ padding: 0.75rem 1.25rem;
517
+ border-radius: 18px 18px 18px 4px;
518
+ max-width: 60%;
519
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
520
+ word-wrap: break-word;">
521
+ <pre class="bubble-content">{html.escape(error_message)}</pre>
522
+ <div style="font-size: 0.7rem; opacity: 0.6; margin-top: 0.3rem;">
523
+ {response_timestamp}
524
+ </div>
525
+ </div>
526
+ </div>
527
+ """,
528
+ unsafe_allow_html=True
529
+ )
530
+
531
+ # アクティビティを更新
532
+ st.session_state.last_activity = datetime.now()
533
+
534
+ # フッター
535
+ st.markdown(
536
+ f"""
537
+ <div style="text-align: center; margin-top: 3rem; padding: 1rem 0;
538
+ border-top: 1px solid #E5E7EB; color: #9CA3AF; font-size: 0.8rem;">
539
+ EmpathemeBot · セッション: {st.session_state.session_id[:8]}
540
+ </div>
541
+ """,
542
+ unsafe_allow_html=True
543
+ )
544
+
545
+ if __name__ == "__main__":
546
+ main()