Song commited on
Commit
a351846
·
1 Parent(s): d1d8455
Files changed (2) hide show
  1. .gitignore +1 -0
  2. app.py +44 -49
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ app_origin.py
app.py CHANGED
@@ -2,7 +2,6 @@
2
  # -*- coding: utf-8 -*-
3
  # ---------- 環境與快取設定 (應置於最前) ----------
4
  import os
5
- import json
6
  import time
7
  from typing import List, Dict, Any
8
  from contextlib import asynccontextmanager
@@ -20,8 +19,8 @@ from linebot.v3.webhook import WebhookParser
20
  from linebot.v3.exceptions import InvalidSignatureError
21
  # --------------------------------------------------------------
22
  from openai import OpenAI
23
- from tavily import TavilyClient # 新增 Tavily 客戶端
24
- from sentence_transformers import SentenceTransformer, util # 新增用於向量相似度排序(CPU友好)
25
 
26
  # ==== CONFIG (從環境變數載入,或使用預設值) ====
27
  def _require_env(var: str) -> str:
@@ -34,24 +33,24 @@ def _require_env(var: str) -> str:
34
  CHANNEL_SECRET = _require_env("CHANNEL_SECRET")
35
  CHANNEL_ACCESS_TOKEN = _require_env("CHANNEL_ACCESS_TOKEN")
36
 
37
- # Tavily API Key (從環境變數讀取以確保安全)
38
- TAVILY_API_KEY = "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"
39
 
40
- # LLM API 設定
41
  LLM_API_CONFIG = {
42
- "base_url": os.getenv("LLM_BASE_URL", "https://litellm-ekkks8gsocw.dgx-coolify.apmic.ai/"),
43
- "api_key": os.getenv("LLM_API_KEY", "sk-eT_04m428oAPUD5kUmIhVA"),
44
  }
45
 
46
- # LLM 模型設定 (改用 azure-gpt-4.1,降低 max_tokens 以避免超時)
47
  LLM_MODEL_CONFIG = {
48
- "model": os.getenv("LLM_MODEL", "azure-gpt-4.1"),
49
- "max_tokens": int(os.getenv("MAX_TOKENS", 2000)), # 降低上限以提升回應速度
50
  "temperature": float(os.getenv("TEMPERATURE", 0.3)),
51
  "seed": int(os.getenv("LLM_SEED", 42)),
52
  }
53
 
54
- # 系統提示詞(精簡版,強調使用最新資料)
55
  SYSTEM_PROMPT = """你是一個友好的AI助手,請用簡單、親切的文字回覆用戶的問題。
56
  回答複雜問題時,先給概念,再給詳細解釋。
57
  使用條列式(如 - 或 1. 2. 3.)整理內容,讓它適合手機閱讀。
@@ -90,9 +89,9 @@ def estimate_tokens(messages: List[Dict[str, str]]) -> int:
90
  total += len(msg["content"].split()) * 1.3 # 粗估 token
91
  return total
92
 
93
- # ---------- 網路搜尋函數(Tavily API,top 5 結果,向量相似度排序,CPU 環境友好) ----------
94
- def perform_web_search(query: str, max_results: int = 5) -> str: # 改為 top 5
95
- """使用 Tavily 進行網路搜尋,計算向量相似度(文字意義)排序結果,並返回摘要。同時 log/print 檢索過程。"""
96
  print(f"開始網路搜尋:查詢詞 = '{query}',最大結果數 = {max_results}")
97
  try:
98
  client = TavilyClient(api_key=TAVILY_API_KEY)
@@ -101,11 +100,10 @@ def perform_web_search(query: str, max_results: int = 5) -> str: # 改為 top 5
101
  print("搜尋完成:沒有找到相關結果。")
102
  return "沒有找到相關的網路搜尋結果。"
103
 
104
- # 加載輕量嵌入模型(all-MiniLM-L6-v2,CPU 友好,無 GPU 依賴)
105
- embedder = SentenceTransformer('all-MiniLM-L6-v2')
106
  query_emb = embedder.encode(query)
107
 
108
- # 計算每個結果的相似度 (文字意義排序)
109
  results_with_scores = []
110
  for result in response['results']:
111
  content = result['content']
@@ -113,7 +111,6 @@ def perform_web_search(query: str, max_results: int = 5) -> str: # 改為 top 5
113
  score = util.cos_sim(query_emb, content_emb)[0][0].item()
114
  results_with_scores.append((score, result))
115
 
116
- # 排序並過濾相似度 > 0.3 的結果(確保相關性)
117
  results_with_scores.sort(key=lambda x: x[0], reverse=True)
118
  relevant_results = [res for score, res in results_with_scores if score > 0.3]
119
 
@@ -123,8 +120,9 @@ def perform_web_search(query: str, max_results: int = 5) -> str: # 改為 top 5
123
 
124
  search_summary = "以下是相關的網路搜尋結果摘要(已按文字相似度排序):\n"
125
  search_summary += f"AI總結:{response.get('answer', '無總結可用')}\n\n"
126
- for i, result in enumerate(relevant_results[:5], 1): # 限制 top 5
127
- print(f"��果 {i}: 標題 = '{result['title']}',內容 = '{result['content'][:200]}...',來源 = '{result['url']}',相似度 = {results_with_scores[i-1][0]:.2f}")
 
128
  search_summary += f"{i}. {result['title']}: {result['content'][:200]}... (來源: {result['url']})\n"
129
  print(f"搜尋完成:總結果數 = {len(response['results'])}, 相關結果數 = {len(relevant_results)}")
130
  return search_summary
@@ -133,18 +131,29 @@ def perform_web_search(query: str, max_results: int = 5) -> str: # 改為 top 5
133
  return f"搜尋時發生錯誤:{str(e)}。請稍後再試。"
134
 
135
  # ---------- 聊天處理流程 (新增 retry 和 timeout) ----------
136
- from tenacity import retry, stop_after_attempt, wait_exponential # 需要 pip install tenacity
137
 
138
  class ChatPipeline:
139
  def __init__(self):
140
  if not LLM_API_CONFIG["api_key"] or not LLM_API_CONFIG["base_url"]:
141
  raise ValueError("LLM API Key or Base URL is not configured.")
142
- self.llm_client = OpenAI(api_key=LLM_API_CONFIG["api_key"], base_url=LLM_API_CONFIG["base_url"])
 
 
 
 
 
 
 
 
 
 
 
 
143
 
144
  @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10))
145
  def _llm_call(self, messages: List[Dict[str, str]]) -> str:
146
  try:
147
- # 估算 token 並 print 監控
148
  token_est = estimate_tokens(messages)
149
  print(f"LLM 呼叫:估計 token = {token_est}")
150
  if token_est > 50000:
@@ -156,7 +165,7 @@ class ChatPipeline:
156
  max_tokens=LLM_MODEL_CONFIG["max_tokens"],
157
  temperature=LLM_MODEL_CONFIG["temperature"],
158
  seed=LLM_MODEL_CONFIG["seed"],
159
- timeout=30.0, # 30 秒 timeout
160
  )
161
  content = response.choices[0].message.content or ""
162
  return content
@@ -168,7 +177,6 @@ class ChatPipeline:
168
  return conversations.get(user_id, [])
169
 
170
  def update_conversation_history(self, user_id: str, messages: List[Dict[str, str]]):
171
- # 限制歷史:保留最近 20 條訊息 (約 10 輪)
172
  recent = messages[-20:]
173
  conversations[user_id] = recent
174
 
@@ -189,21 +197,19 @@ class ChatPipeline:
189
  messages = [{"role": "system", "content": SYSTEM_PROMPT}]
190
  messages.extend(history)
191
  messages.append({"role": "user", "content": user_text})
192
- if "沒有找到" not in search_results: # 只在有結果時加入
193
  messages.append({"role": "system", "content": f"網路搜尋結果:{search_results}"})
194
 
195
  response = self._llm_call(messages)
196
  response = response.replace('*', '')
197
 
198
- # 更新歷史紀錄
199
  history.append({"role": "user", "content": user_text})
200
  history.append({"role": "assistant", "content": response})
201
  self.update_conversation_history(user_id, history)
202
 
203
- # 如果回應過長,檢查 chunks 數量,如果超過5,生成摘要
204
  chunks = split_text_for_line(response)
205
  if len(chunks) > 5:
206
- summary_prompt = [{"role": "system", "content": "請將以下內容生成一個簡潔但完整的中文摘要,保留關鍵事實和細節,長度控制在20000字元內。"}]
207
  summary_prompt.append({"role": "user", "content": response})
208
  summary = self._llm_call(summary_prompt)
209
  summary = summary.replace('*', '')
@@ -219,22 +225,17 @@ async def lifespan(app: FastAPI):
219
  yield
220
 
221
  app = FastAPI(lifespan=lifespan)
222
- chat_pipeline = None
223
 
224
- # ----------------- LINE Bot API v3 初始化修正 -----------------
225
- # 建立一個 Configuration 物件,並傳入你的 Access Token
226
  configuration = Configuration(access_token=CHANNEL_ACCESS_TOKEN)
227
- # 使用 Configuration 物件來初始化 AsyncApiClient 和 AsyncMessagingApi
228
  async_api_client = AsyncApiClient(configuration)
229
  line_bot_api = AsyncMessagingApi(async_api_client)
230
- # 建立 WebhookParser 來解析請求
231
  parser = WebhookParser(CHANNEL_SECRET)
232
- # --------------------------------------------------------------
233
 
234
  # ---------- LINE Webhook 處理 ----------
235
  @app.post("/webhook")
236
  async def line_webhook(request: Request):
237
- # 驗證簽名
238
  signature = request.headers['X-Line-Signature']
239
  body = await request.body()
240
  try:
@@ -243,7 +244,6 @@ async def line_webhook(request: Request):
243
  raise HTTPException(status_code=400, detail="Invalid signature")
244
 
245
  for event in events:
246
- # 只處理文字訊息事件
247
  if event.type != 'message' or event.message.type != 'text':
248
  continue
249
 
@@ -256,7 +256,6 @@ async def line_webhook(request: Request):
256
 
257
  try:
258
  if user_text.lower() == "繼續" and user_id in pending_chunks:
259
- # 處理繼續發送剩餘 chunks
260
  remaining = pending_chunks[user_id]
261
  if not remaining:
262
  ai_response = "沒有更多內容了。"
@@ -276,22 +275,19 @@ async def line_webhook(request: Request):
276
  messages=messages_to_send
277
  )
278
  )
279
- continue # 結束本次處理
280
 
281
- # 正常處理查詢
282
  ai_response = chat_pipeline.answer_question(user_id, user_text)
283
  chunks = split_text_for_line(ai_response)
284
 
285
  if len(chunks) <= 5:
286
  messages_to_send = [TextMessage(text=chunk) for chunk in chunks]
287
  else:
288
- # 發送前5個,並儲存剩餘
289
  chunks_to_send = chunks[:5]
290
  messages_to_send = [TextMessage(text=chunk) for chunk in chunks_to_send]
291
  messages_to_send[-1].text += "\n\n內容過長,請回覆 '繼續' 以查看下一部分。"
292
  pending_chunks[user_id] = chunks[5:]
293
 
294
- # 發送訊息
295
  await line_bot_api.reply_message(
296
  ReplyMessageRequest(
297
  reply_token=reply_token,
@@ -301,12 +297,11 @@ async def line_webhook(request: Request):
301
  except Exception as e:
302
  print(f"Error processing message: {e}")
303
  error_message = "抱歉,系統發生錯誤,請稍後再試。"
304
- # 使用 await 來呼叫非同步的 reply_message
305
  await line_bot_api.reply_message(
306
- ReplyMessageRequest(
307
- reply_token=reply_token,
308
- messages=[TextMessage(text=error_message)]
309
- )
310
  )
311
 
312
  return {"status": "ok"}
@@ -316,7 +311,7 @@ async def line_webhook(request: Request):
316
  async def health_check():
317
  return {"status": "ok"}
318
 
319
- # 根路由,避免 404
320
  @app.get("/")
321
  async def root():
322
  return {"message": "LINE Bot is running"}
 
2
  # -*- coding: utf-8 -*-
3
  # ---------- 環境與快取設定 (應置於最前) ----------
4
  import os
 
5
  import time
6
  from typing import List, Dict, Any
7
  from contextlib import asynccontextmanager
 
19
  from linebot.v3.exceptions import InvalidSignatureError
20
  # --------------------------------------------------------------
21
  from openai import OpenAI
22
+ from tavily import TavilyClient # Tavily 客戶端
23
+ from sentence_transformers import SentenceTransformer, util # 用於向量相似度排序(CPU友好)
24
 
25
  # ==== CONFIG (從環境變數載入,或使用預設值) ====
26
  def _require_env(var: str) -> str:
 
33
  CHANNEL_SECRET = _require_env("CHANNEL_SECRET")
34
  CHANNEL_ACCESS_TOKEN = _require_env("CHANNEL_ACCESS_TOKEN")
35
 
36
+ # Tavily API Key (強制從環境變數讀取,移除硬編碼)
37
+ TAVILY_API_KEY = _require_env("TAVILY_API_KEY")
38
 
39
+ # LLM API 設定(改用 OpenRouter)
40
  LLM_API_CONFIG = {
41
+ "base_url": os.getenv("LLM_BASE_URL", "https://openrouter.ai/api/v1"),
42
+ "api_key": _require_env("OPENROUTER_API_KEY"), # 強制要求 OpenRouter API Key
43
  }
44
 
45
+ # LLM 模型設定 (預設改用 gpt-4o,性價比高)
46
  LLM_MODEL_CONFIG = {
47
+ "model": os.getenv("LLM_MODEL", "xiaomi/mimo-v2-flash:free"),
48
+ "max_tokens": int(os.getenv("MAX_TOKENS", 2000)),
49
  "temperature": float(os.getenv("TEMPERATURE", 0.3)),
50
  "seed": int(os.getenv("LLM_SEED", 42)),
51
  }
52
 
53
+ # 系統提示詞(保持原樣)
54
  SYSTEM_PROMPT = """你是一個友好的AI助手,請用簡單、親切的文字回覆用戶的問題。
55
  回答複雜問題時,先給概念,再給詳細解釋。
56
  使用條列式(如 - 或 1. 2. 3.)整理內容,讓它適合手機閱讀。
 
89
  total += len(msg["content"].split()) * 1.3 # 粗估 token
90
  return total
91
 
92
+ # ---------- 網路搜尋函數(優化:嵌入模型由 ChatPipeline 預載) ----------
93
+ def perform_web_search(query: str, max_results: int = 5) -> str:
94
+ """使用 Tavily 進行網路搜尋,計算向量相似度排序結果,並返回摘要。"""
95
  print(f"開始網路搜尋:查詢詞 = '{query}',最大結果數 = {max_results}")
96
  try:
97
  client = TavilyClient(api_key=TAVILY_API_KEY)
 
100
  print("搜尋完成:沒有找到相關結果。")
101
  return "沒有找到相關的網路搜尋結果。"
102
 
103
+ # 使用 ChatPipeline 中預載的 embedder
104
+ embedder = chat_pipeline.embedder
105
  query_emb = embedder.encode(query)
106
 
 
107
  results_with_scores = []
108
  for result in response['results']:
109
  content = result['content']
 
111
  score = util.cos_sim(query_emb, content_emb)[0][0].item()
112
  results_with_scores.append((score, result))
113
 
 
114
  results_with_scores.sort(key=lambda x: x[0], reverse=True)
115
  relevant_results = [res for score, res in results_with_scores if score > 0.3]
116
 
 
120
 
121
  search_summary = "以下是相關的網路搜尋結果摘要(已按文字相似度排序):\n"
122
  search_summary += f"AI總結:{response.get('answer', '無總結可用')}\n\n"
123
+ for i, result in enumerate(relevant_results[:5], 1):
124
+ score = results_with_scores[i-1][0]
125
+ print(f"結果 {i}: 標題 = '{result['title']}',內容 = '{result['content'][:200]}...',來源 = '{result['url']}',相似度 = {score:.2f}")
126
  search_summary += f"{i}. {result['title']}: {result['content'][:200]}... (來源: {result['url']})\n"
127
  print(f"搜尋完成:總結果數 = {len(response['results'])}, 相關結果數 = {len(relevant_results)}")
128
  return search_summary
 
131
  return f"搜尋時發生錯誤:{str(e)}。請稍後再試。"
132
 
133
  # ---------- 聊天處理流程 (新增 retry 和 timeout) ----------
134
+ from tenacity import retry, stop_after_attempt, wait_exponential
135
 
136
  class ChatPipeline:
137
  def __init__(self):
138
  if not LLM_API_CONFIG["api_key"] or not LLM_API_CONFIG["base_url"]:
139
  raise ValueError("LLM API Key or Base URL is not configured.")
140
+
141
+ # 預載入嵌入模型(大幅提升搜尋速度)
142
+ self.embedder = SentenceTransformer('all-MiniLM-L6-v2')
143
+
144
+ # OpenAI client(相容 OpenRouter,並加入建議 headers)
145
+ self.llm_client = OpenAI(
146
+ api_key=LLM_API_CONFIG["api_key"],
147
+ base_url=LLM_API_CONFIG["base_url"],
148
+ default_headers={
149
+ "HTTP-Referer": os.getenv("SITE_URL", "https://your-line-bot.example.com"), # 建議設定你的網站域名
150
+ "X-Title": os.getenv("SITE_NAME", "My LINE Bot"), # 建議設定 Bot 名稱
151
+ }
152
+ )
153
 
154
  @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10))
155
  def _llm_call(self, messages: List[Dict[str, str]]) -> str:
156
  try:
 
157
  token_est = estimate_tokens(messages)
158
  print(f"LLM 呼叫:估計 token = {token_est}")
159
  if token_est > 50000:
 
165
  max_tokens=LLM_MODEL_CONFIG["max_tokens"],
166
  temperature=LLM_MODEL_CONFIG["temperature"],
167
  seed=LLM_MODEL_CONFIG["seed"],
168
+ timeout=30.0,
169
  )
170
  content = response.choices[0].message.content or ""
171
  return content
 
177
  return conversations.get(user_id, [])
178
 
179
  def update_conversation_history(self, user_id: str, messages: List[Dict[str, str]]):
 
180
  recent = messages[-20:]
181
  conversations[user_id] = recent
182
 
 
197
  messages = [{"role": "system", "content": SYSTEM_PROMPT}]
198
  messages.extend(history)
199
  messages.append({"role": "user", "content": user_text})
200
+ if "沒有找到" not in search_results:
201
  messages.append({"role": "system", "content": f"網路搜尋結果:{search_results}"})
202
 
203
  response = self._llm_call(messages)
204
  response = response.replace('*', '')
205
 
 
206
  history.append({"role": "user", "content": user_text})
207
  history.append({"role": "assistant", "content": response})
208
  self.update_conversation_history(user_id, history)
209
 
 
210
  chunks = split_text_for_line(response)
211
  if len(chunks) > 5:
212
+ summary_prompt = [{"role": "system", "content": "請將以下內容生成一個簡潔但完整的中文摘要,保留關鍵事實和細節,長度控制在2000字元內。"}]
213
  summary_prompt.append({"role": "user", "content": response})
214
  summary = self._llm_call(summary_prompt)
215
  summary = summary.replace('*', '')
 
225
  yield
226
 
227
  app = FastAPI(lifespan=lifespan)
228
+ chat_pipeline = None # 會在 lifespan 中初始化
229
 
230
+ # ----------------- LINE Bot API v3 初始化 -----------------
 
231
  configuration = Configuration(access_token=CHANNEL_ACCESS_TOKEN)
 
232
  async_api_client = AsyncApiClient(configuration)
233
  line_bot_api = AsyncMessagingApi(async_api_client)
 
234
  parser = WebhookParser(CHANNEL_SECRET)
 
235
 
236
  # ---------- LINE Webhook 處理 ----------
237
  @app.post("/webhook")
238
  async def line_webhook(request: Request):
 
239
  signature = request.headers['X-Line-Signature']
240
  body = await request.body()
241
  try:
 
244
  raise HTTPException(status_code=400, detail="Invalid signature")
245
 
246
  for event in events:
 
247
  if event.type != 'message' or event.message.type != 'text':
248
  continue
249
 
 
256
 
257
  try:
258
  if user_text.lower() == "繼續" and user_id in pending_chunks:
 
259
  remaining = pending_chunks[user_id]
260
  if not remaining:
261
  ai_response = "沒有更多內容了。"
 
275
  messages=messages_to_send
276
  )
277
  )
278
+ continue
279
 
 
280
  ai_response = chat_pipeline.answer_question(user_id, user_text)
281
  chunks = split_text_for_line(ai_response)
282
 
283
  if len(chunks) <= 5:
284
  messages_to_send = [TextMessage(text=chunk) for chunk in chunks]
285
  else:
 
286
  chunks_to_send = chunks[:5]
287
  messages_to_send = [TextMessage(text=chunk) for chunk in chunks_to_send]
288
  messages_to_send[-1].text += "\n\n內容過長,請回覆 '繼續' 以查看下一部分。"
289
  pending_chunks[user_id] = chunks[5:]
290
 
 
291
  await line_bot_api.reply_message(
292
  ReplyMessageRequest(
293
  reply_token=reply_token,
 
297
  except Exception as e:
298
  print(f"Error processing message: {e}")
299
  error_message = "抱歉,系統發生錯誤,請稍後再試。"
 
300
  await line_bot_api.reply_message(
301
+ ReplyMessageRequest(
302
+ reply_token=reply_token,
303
+ messages=[TextMessage(text=error_message)]
304
+ )
305
  )
306
 
307
  return {"status": "ok"}
 
311
  async def health_check():
312
  return {"status": "ok"}
313
 
314
+ # 根路由
315
  @app.get("/")
316
  async def root():
317
  return {"message": "LINE Bot is running"}