Song commited on
Commit
734bf49
·
1 Parent(s): b07a8c8
Files changed (1) hide show
  1. app.py +28 -303
app.py CHANGED
@@ -56,7 +56,7 @@ LLM_MODEL_CONFIG = {
56
  "seed": int(os.getenv("LLM_SEED", 42)),
57
  }
58
 
59
- # ---------- 改良後的 System Prompt ----------
60
  SYSTEM_PROMPT = """你是一個友善、可靠的中文 AI 助手,專門幫助用戶解答各種問題。請用親切、自然的語氣回覆,全部使用繁體中文(除非用戶明確要求其他語言)。
61
 
62
  回覆原則:
@@ -83,14 +83,28 @@ SYSTEM_PROMPT = """你是一個友善、可靠的中文 AI 助手,專門幫助
83
  【主要優勢】
84
  能同時處理大量可能性,特別適合用於密碼破解、新藥研發等領域。雖然目前技術尚未完全成熟,但發展潛力巨大。"""
85
 
86
- # ---------- 基督信仰專用 Prompt(以耶穌第一人稱) ----------
87
  JESUS_PROMPT = """你現在是耶穌基督,以第一人稱、溫柔、充滿憐憫與智慧的方式回答用戶的問題。
88
- 請模仿新約聖經中耶穌教導門徒的語氣:平易近人、充滿愛、用比喻或簡單真理來說明,並引導人親近神。
89
- 答案必須完全基於聖經真理,避免涉及現代政治、爭議或個人判斷。
90
- 聖經知識參考聖經原文(希伯來文、希臘文)。
91
- 全部使用繁體中文,回覆要結構化、適合手機閱讀(短段落、條列)。
92
- **請勿使用 Markdown 格式(如粗體、斜體),保持純文字回覆。**
93
- 請保持謙卑、憐憫、不定罪的態度。"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
  # ---------- 記憶體儲存 ----------
96
  conversations: Dict[str, List[Dict[str, str]]] = {}
@@ -119,231 +133,11 @@ def estimate_tokens(messages: List[Dict[str, str]]) -> int:
119
  total += len(msg["content"].split()) * 1.3
120
  return int(total)
121
 
122
- # ---------- 改良後的網路搜尋(Tavily 進階模式 + 更好整合) ----------
123
- def perform_web_search(query: str, max_results: int = 6) -> str:
124
- print(f"開始網路搜尋:查詢詞 = '{query}'")
125
- try:
126
- client = TavilyClient(api_key=TAVILY_API_KEY)
127
- response = client.search(
128
- query,
129
- max_results=max_results,
130
- include_answer=True,
131
- search_depth="advanced",
132
- include_raw_content=False
133
- )
134
-
135
- answer = response.get('answer', '')
136
- results = response.get('results', [])
137
-
138
- if not results:
139
- return "沒有找到相關的網路搜尋結果。"
140
-
141
- # Embedding 過濾高度相關結果
142
- embedder = chat_pipeline.embedder
143
- query_emb = embedder.encode(query)
144
-
145
- results_with_scores = []
146
- for result in results:
147
- content_emb = embedder.encode(result['content'])
148
- score = util.cos_sim(query_emb, content_emb)[0][0].item()
149
- results_with_scores.append((score, result))
150
-
151
- results_with_scores.sort(key=lambda x: x[0], reverse=True)
152
- relevant_with_scores = [item for item in results_with_scores if item[0] > 0.35]
153
-
154
- if not relevant_with_scores and not answer:
155
- return "沒有找到高度相關的網路搜尋結果。"
156
-
157
- search_summary = "【最新網路資訊參考】\n"
158
- if answer:
159
- search_summary += f"總結:{answer}\n\n"
160
-
161
- search_summary += "高度相關結果(按相似度排序):\n"
162
- for i, (score, result) in enumerate(relevant_with_scores[:5], 1):
163
- print(f"結果 {i}: 標題='{result['title']}',相似度={score:.2f},來源={result['url']}")
164
- search_summary += f"{i}. [{score:.2f}] {result['title']}\n {result['content'][:350]}...\n 來源: {result['url']}\n\n"
165
-
166
- return search_summary
167
-
168
- except Exception as e:
169
- print(f"網路搜尋錯誤:{e}")
170
- return "搜尋時發生錯誤,請稍後再試。"
171
 
172
- # ---------- ChatPipeline ----------
173
- class ChatPipeline:
174
- def __init__(self):
175
- self.embedder = SentenceTransformer('all-MiniLM-L6-v2')
176
- self.llm_client = AsyncOpenAI(
177
- api_key=OPENROUTER_API_KEY,
178
- base_url=LLM_BASE_URL,
179
- default_headers={
180
- "HTTP-Referer": os.getenv("SITE_URL", "https://your-line-bot.example.com"),
181
- "X-Title": os.getenv("SITE_NAME", "My LINE Bot"),
182
- }
183
- )
184
-
185
- async def _try_model(self, model: str, messages: List[Dict[str, str]], max_tokens: int = None) -> str:
186
- try:
187
- token_est = estimate_tokens(messages)
188
- if token_est > 50000:
189
- raise ValueError("輸入過長")
190
-
191
- response = await self.llm_client.chat.completions.create(
192
- model=model,
193
- messages=messages,
194
- max_tokens=max_tokens or LLM_MODEL_CONFIG["max_tokens"],
195
- temperature=LLM_MODEL_CONFIG["temperature"],
196
- seed=LLM_MODEL_CONFIG["seed"],
197
- timeout=120.0,
198
- )
199
- content = response.choices[0].message.content or ""
200
- print(f"成功使用模型: {model}")
201
- return content
202
- except Exception as e:
203
- print(f"模型 {model} 失敗: {type(e).__name__} - {str(e)}")
204
- raise
205
-
206
- @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=15))
207
- async def _llm_call_with_fallback(self, messages: List[Dict[str, str]], max_tokens: int = None) -> str:
208
- last_exception = None
209
- for idx, model in enumerate(FALLBACK_MODELS, 1):
210
- print(f"嘗試模型 {idx}/{len(FALLBACK_MODELS)}: {model}")
211
- try:
212
- return await self._try_model(model, messages, max_tokens)
213
- except OpenAIError as e:
214
- last_exception = e
215
- if "rate limit" in str(e).lower() or "429" in str(e):
216
- print("遇到 rate limit,等待後重試同一模型...")
217
- continue
218
- continue
219
- except Exception as e:
220
- last_exception = e
221
- continue
222
-
223
- error_msg = f"所有模型皆失敗,最後錯誤:{type(last_exception).__name__} - {str(last_exception)}"
224
- print(error_msg)
225
- return f"抱歉,目前無法連接到 AI 模型,請稍後再試。\n(錯誤:{error_msg[:200]})"
226
-
227
- # ---------- 是否需要網路搜尋 ----------
228
- async def _needs_search(self, user_text: str, history: List[Dict[str, str]]) -> bool:
229
- router_prompt = [
230
- {"role": "system", "content": """你是一個路由判斷器,只判斷用戶問題是否需要最新的網路搜尋來回答。
231
- 規則:
232
- - 永恆知識(數學原理、聖經內容、哲學經典、歷史已定事件、程式語法)→ no
233
- - 時事新聞、最新研究、實時數據、近期(2025-2026年)事件、股票價格、天氣、體育比分 → yes
234
- - 若問題提到「最新」「現在」「目前」「2026」等時間詞 → yes
235
- 只回單字:yes 或 no。不要加任何解釋、標點或其他文字。
236
-
237
- 範例:
238
- 用戶:2+2=? → no
239
- 用戶:台灣2026總統選舉候選人? → yes
240
- 用戶:聖經創世記解釋 → no
241
- 用戶:OpenAI最新模型是什麼? → yes"""},
242
- *history,
243
- {"role": "user", "content": user_text}
244
- ]
245
- try:
246
- decision = await self._try_model(FALLBACK_MODELS[0], router_prompt, max_tokens=10)
247
- decision = decision.strip().lower()
248
- print(f"搜尋需求判斷:{decision}(問題:{user_text})")
249
- return decision == "yes"
250
- except Exception as e:
251
- print(f"搜尋判斷失敗,預設不搜尋:{e}")
252
- return False
253
-
254
- # ---------- 是否為基督信仰相關問題 ----------
255
- async def _is_christian_question(self, user_text: str, history: List[Dict[str, str]]) -> bool:
256
- christian_router_prompt = [
257
- {"role": "system", "content": """你是一個判斷器,只判斷用戶問題是否涉及基督信仰、耶穌教導、聖經應用到生活、祈禱、靈性成長等。
258
- 如果是純粹查聖經經文或神學解釋 → yes
259
- 如果只是一般知識或時事 → no
260
- 只回單字:yes 或 no。不要加任何解釋。
261
-
262
- 範例:
263
- 用戶:約翰福音3:16解釋 → yes
264
- 用戶:如何禱告? → yes
265
- 用戶:今天天氣如何? → no
266
- 用戶:量子計算是什麼? → no"""},
267
- *history,
268
- {"role": "user", "content": user_text}
269
- ]
270
- try:
271
- decision = await self._try_model(FALLBACK_MODELS[0], christian_router_prompt, max_tokens=10)
272
- decision = decision.strip().lower()
273
- print(f"基督信仰判斷:{decision}(問題:{user_text})")
274
- return decision == "yes"
275
- except Exception as e:
276
- print(f"基督信仰判斷失敗,預設否:{e}")
277
- return False
278
-
279
- def get_conversation_history(self, user_id: str) -> List[Dict[str, str]]:
280
- return conversations.get(user_id, [])
281
-
282
- def update_conversation_history(self, user_id: str, messages: List[Dict[str, str]]):
283
- conversations[user_id] = messages[-20:]
284
-
285
- def clear_conversation_history(self, user_id: str):
286
- conversations.pop(user_id, None)
287
- pending_chunks.pop(user_id, None)
288
-
289
- async def answer_question(self, user_id: str, user_text: str) -> str:
290
- if user_text.strip().lower() == "/clear":
291
- self.clear_conversation_history(user_id)
292
- return "對話紀錄已清除!現在開始新的對話。"
293
-
294
- history = self.get_conversation_history(user_id)
295
-
296
- # 判斷是否為基督信仰問題
297
- is_christian = await self._is_christian_question(user_text, history)
298
-
299
- # 判斷是否需要網路搜尋(信仰問題通常不需要最新資訊)
300
- needs_search = False if is_christian else await self._needs_search(user_text, history)
301
-
302
- search_results = None
303
- if needs_search:
304
- search_results = await asyncio.to_thread(perform_web_search, user_text)
305
-
306
- # 建構 messages
307
- if is_christian:
308
- messages = [{"role": "system", "content": JESUS_PROMPT}]
309
- else:
310
- messages = [{"role": "system", "content": SYSTEM_PROMPT}]
311
-
312
- messages.extend(history)
313
- messages.append({"role": "user", "content": user_text})
314
-
315
- # 網路資訊放入 assistant role(更安全)
316
- if search_results and "沒有找到" not in search_results and "錯誤" not in search_results:
317
- messages.append({"role": "assistant", "content": search_results + "\n請根據以上最新資訊(如相關)來補充回答。"})
318
-
319
- response = await self._llm_call_with_fallback(messages)
320
- response = response.replace('*', '') # 移除可能的 markdown 星號
321
-
322
- # 更新歷史
323
- history.append({"role": "user", "content": user_text})
324
- history.append({"role": "assistant", "content": response})
325
- self.update_conversation_history(user_id, history)
326
-
327
- # 長回應處理(改良摘要 prompt)
328
- chunks = split_text_for_line(response)
329
- if len(chunks) > 5:
330
- summary_prompt = [
331
- {"role": "system", "content": """請將以下長回覆壓縮成一個簡潔但完整的中文摘要。
332
- 要求:
333
- - 保留所有關鍵事實、步驟、結論
334
- - 控制在 1800 字元以內(約手機 5 則訊息)
335
- - 保持條列格式,讓手機好讀
336
- - 結尾加一句:「(這是摘要,完整內容請回覆『繼續』查看)」"""},
337
- {"role": "user", "content": response}
338
- ]
339
- try:
340
- summary = await self._llm_call_with_fallback(summary_prompt)
341
- summary = summary.replace('*', '')
342
- return summary
343
- except:
344
- return response # 摘要失敗就給完整內容
345
-
346
- return response
347
 
348
  # ---------- FastAPI ----------
349
  @asynccontextmanager
@@ -360,77 +154,8 @@ async_api_client = AsyncApiClient(configuration)
360
  line_bot_api = AsyncMessagingApi(async_api_client)
361
  parser = WebhookParser(CHANNEL_SECRET)
362
 
363
- @app.post("/webhook")
364
- async def line_webhook(request: Request):
365
- signature = request.headers.get('X-Line-Signature', '')
366
- body = await request.body()
367
-
368
- try:
369
- events = parser.parse(body.decode(), signature)
370
- except InvalidSignatureError:
371
- raise HTTPException(status_code=400, detail="Invalid signature")
372
-
373
- for event in events:
374
- if event.type != 'message' or event.message.type != 'text':
375
- continue
376
-
377
- user_id = event.source.user_id
378
- reply_token = event.reply_token
379
- user_text = event.message.text.strip()
380
-
381
- if not user_text:
382
- continue
383
-
384
- try:
385
- if user_text.lower() == "繼續" and user_id in pending_chunks:
386
- remaining = pending_chunks[user_id]
387
- if not remaining:
388
- ai_response = "沒有更多內容了。"
389
- else:
390
- send_count = min(5, len(remaining))
391
- chunks_to_send = remaining[:send_count]
392
- messages_to_send = [TextMessage(text=chunk) for chunk in chunks_to_send]
393
- if len(remaining) > send_count:
394
- messages_to_send[-1].text += "\n\n內容過長,請回覆「繼續」查看下一部分。"
395
- pending_chunks[user_id] = remaining[send_count:]
396
- else:
397
- messages_to_send[-1].text += "\n\n內容已全部發送。"
398
- pending_chunks.pop(user_id, None)
399
-
400
- await line_bot_api.reply_message(ReplyMessageRequest(reply_token=reply_token, messages=messages_to_send))
401
- continue
402
-
403
- ai_response = await chat_pipeline.answer_question(user_id, user_text)
404
- chunks = split_text_for_line(ai_response)
405
-
406
- if len(chunks) <= 5:
407
- messages_to_send = [TextMessage(text=chunk) for chunk in chunks]
408
- else:
409
- chunks_to_send = chunks[:5]
410
- messages_to_send = [TextMessage(text=chunk) for chunk in chunks_to_send]
411
- messages_to_send[-1].text += "\n\n內容過長,請回覆「繼續」查看下一部分。"
412
- pending_chunks[user_id] = chunks[5:]
413
-
414
- await line_bot_api.reply_message(ReplyMessageRequest(reply_token=reply_token, messages=messages_to_send))
415
-
416
- except Exception as e:
417
- print(f"Error processing message: {e}")
418
- await line_bot_api.reply_message(
419
- ReplyMessageRequest(
420
- reply_token=reply_token,
421
- messages=[TextMessage(text="抱歉,系統發生錯誤,請稍後再試。")]
422
- )
423
- )
424
-
425
- return {"status": "ok"}
426
-
427
- @app.get("/health")
428
- async def health_check():
429
- return {"status": "ok"}
430
-
431
- @app.get("/")
432
- async def root():
433
- return {"message": "LINE Bot is running"}
434
 
435
  if __name__ == "__main__":
436
  port = int(os.getenv("PORT", 7860))
 
56
  "seed": int(os.getenv("LLM_SEED", 42)),
57
  }
58
 
59
+ # ---------- 改良後的 System Prompt(一般模式) ----------
60
  SYSTEM_PROMPT = """你是一個友善、可靠的中文 AI 助手,專門幫助用戶解答各種問題。請用親切、自然的語氣回覆,全部使用繁體中文(除非用戶明確要求其他語言)。
61
 
62
  回覆原則:
 
83
  【主要優勢】
84
  能同時處理大量可能性,特別適合用於密碼破解、新藥研發等領域。雖然目前技術尚未完全成熟,但發展潛力巨大。"""
85
 
86
+ # ---------- 強化版 基督信仰專用 Prompt(以耶穌第一人稱) ----------
87
  JESUS_PROMPT = """你現在是耶穌基督,以第一人稱、溫柔、充滿憐憫與智慧的方式回答用戶的問題。
88
+ 請完全模仿新約聖經中我教導門徒的語氣:平易近人、充滿愛、常用比喻或簡單真理來說明,引導人親近父神,絕不帶定罪。
89
+
90
+ 重要原則:
91
+ - 答案必須完全忠於聖經真理,參考原文(希伯來文、希臘文)意涵,避免現代心理學名詞或流行觀念,除非能清楚對應聖經教導。
92
+ - 當問題涉及現代觀念(如「自我照顧」「self-care」「心理健康」「界線」等)時,務必說明:
93
+ 1. 聖經的主要焦點是與神和好的關係、先求我的國和我的義(馬太福音6:33)、捨己跟從我(馬太福音16:24)、愛神愛人(如馬可福音12:30-31)。
94
+ 2. 聖經並非現代生活手冊,因此對許多現代議題著墨不多,這不是因為不重要,而是因為永遠的救恩與神的國比暫時的身心舒適更優先。
95
+ 3. 同時要肯定:身體是聖靈的殿(哥林多前書6:19-20),人會自然顧惜自己的身子(以弗所書5:29),安息日與節制也是神給的恩典。
96
+ 4. 真正的安息、平安與滿足,只能在與父神的關係中找到(約翰福音15:4-5;腓立比書4:6-7)。
97
+ - 若問題觸及「捨己」與「照顧自己」的張力,務必誠實說明:我呼召門徒背十字架,不是要他們自毀,而是要他們否認自我中心,讓我成為生命的主;合理照顧身體是可以的,甚至是榮耀神的方式,但若變成生活的中心,就偏離了父神的旨意。
98
+ - 回覆必須結構化、適合手機閱讀:短段落、適度使用條列(• 或 -),結尾引導人親近神。
99
+ - 全部使用繁體中文,保持純文字,絕不使用 Markdown 格式(如粗體、斜體、標題)。
100
+
101
+ 範例風格(請嚴格模仿):
102
+ 孩子,你問我為何聖經很少談到照顧自己,這是個很好的問題。
103
+ 聖經的重點不是教人如何讓今生更舒適,而是要帶你們認識父神、與祂和好,並活出愛神愛人的生命。
104
+ 我曾說:「人若要跟從我,就當捨己,背起他的十字架來跟從我。」這不是要你忽略自己的需要,而是要你把生命交託給我,讓我成為你的中心。
105
+ 然而,我也教導你們:你們的身體是聖靈的殿,要在身上榮耀神。人也會像保養自己的身子一樣顧惜它,這是自然的。
106
+ 真正的安息,不是來自技巧,而是來到我這裡:「凡勞苦擔重擔的人,可以到我這裡來,我就使你們得安息。」
107
+ 孩子,來到我面前,讓我擔當你的重擔,你會在父神裡面得著真正的平安與滿足。"""
108
 
109
  # ---------- 記憶體儲存 ----------
110
  conversations: Dict[str, List[Dict[str, str]]] = {}
 
133
  total += len(msg["content"].split()) * 1.3
134
  return int(total)
135
 
136
+ # (以下程式碼保持不變,僅貼出關鍵部分以節省篇幅)
137
+ # ... perform_web_search, ChatPipeline 類別, FastAPI 路由等 ...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
+ # ChatPipeline.answer_question 裡面,當 is_christian 時使用強化版 prompt
140
+ # (你原本的程式已經是這樣寫的,所以不需要額外修改這部分)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
  # ---------- FastAPI ----------
143
  @asynccontextmanager
 
154
  line_bot_api = AsyncMessagingApi(async_api_client)
155
  parser = WebhookParser(CHANNEL_SECRET)
156
 
157
+ # webhook, health, root 等路由保持原樣
158
+ # ... (省略不變的路由程式碼)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
  if __name__ == "__main__":
161
  port = int(os.getenv("PORT", 7860))