Song commited on
Commit
c59d669
·
1 Parent(s): 2620fa6
Files changed (2) hide show
  1. __pycache__/app.cpython-314.pyc +0 -0
  2. app.py +104 -335
__pycache__/app.cpython-314.pyc DELETED
Binary file (28.7 kB)
 
app.py CHANGED
@@ -1,14 +1,14 @@
1
  #!/usr/bin/env python3
2
  # -*- coding: utf-8 -*-
3
- # ---------- 環境與快取設定 (應置於最前) ----------
4
  import os
5
  import asyncio
6
  from typing import List, Dict
7
  from contextlib import asynccontextmanager
 
8
  from fastapi import FastAPI, Request, HTTPException
9
  import uvicorn
10
 
11
- # ----------------- LINE Bot SDK v3 -----------------
12
  from linebot.v3.messaging import (
13
  AsyncApiClient,
14
  AsyncMessagingApi,
@@ -19,12 +19,10 @@ from linebot.v3.messaging import (
19
  from linebot.v3.webhook import WebhookParser
20
  from linebot.v3.exceptions import InvalidSignatureError
21
 
22
- from openai import AsyncOpenAI, OpenAIError
23
- from tavily import TavilyClient
24
- from sentence_transformers import SentenceTransformer, util
25
  from tenacity import retry, stop_after_attempt, wait_exponential
26
 
27
- # ==== CONFIG ====
28
  def _require_env(var: str) -> str:
29
  v = os.getenv(var)
30
  if not v:
@@ -33,58 +31,9 @@ def _require_env(var: str) -> str:
33
 
34
  CHANNEL_SECRET = _require_env("CHANNEL_SECRET")
35
  CHANNEL_ACCESS_TOKEN = _require_env("CHANNEL_ACCESS_TOKEN")
36
- TAVILY_API_KEY = _require_env("TAVILY_API_KEY")
37
  OPENROUTER_API_KEY = _require_env("OPENROUTER_API_KEY")
38
 
39
- # OpenRouter 官方 endpoint
40
- LLM_BASE_URL = "https://openrouter.ai/api/v1"
41
-
42
- # 模型 fallback 順序(免費模型優先)
43
- FALLBACK_MODELS = [
44
- "arcee-ai/trinity-large-preview:free",
45
- "stepfun/step-3.5-flash:free",
46
- "tngtech/deepseek-r1t2-chimera:free",
47
- "nvidia/nemotron-3-nano-30b-a3b:free",
48
- "tngtech/tng-r1t-chimera:free",
49
- "tngtech/deepseek-r1t-chimera:free",
50
- "deepseek/deepseek-r1-0528:free",
51
- ]
52
-
53
- LLM_MODEL_CONFIG = {
54
- "max_tokens": int(os.getenv("MAX_TOKENS", 4000)),
55
- "temperature": float(os.getenv("TEMPERATURE", 0.7)),
56
- "frequency_penalty": 0.5,
57
- "presence_penalty": 0.3,
58
- }
59
-
60
- # ---------- 改良後的 System Prompt ----------
61
- SYSTEM_PROMPT = """你是一個友善、可靠的中文 AI 助手,專門幫助用戶解答各種問題。請用親切、自然的語氣回覆,全部使用繁體中文(除非用戶明確要求其他語言)。
62
-
63
- 回覆原則:
64
- 1. **手機閱讀優化**:內容必須適合手機螢幕閱讀。請使用短段落,避免長篇大論。
65
- 2. **格式限制**:由於平台不支援 Markdown 語法,**請勿使用** **粗體**、*斜體*、# 標題 或 [連結](URL) 等格式。請直接使用純文字,並利用空行來區隔段落。
66
- 3. **結構清晰**:
67
- - 複雜問題先給「簡短結論」,再條列說明細節。
68
- - 善用條列式(使用「•」或「-」)來整理資訊。
69
- - 數學公式用簡單文字描述(如「面積 = 長 × 寬」),避免使用複雜符號。
70
- 4. **資訊來源處理**:
71
- - 若有提供網路搜尋結果,請優先參考最新資訊,但務必自行過濾無關內容。
72
- - 永恆知識(如歷史、科學原理)以你自身的知識為主。
73
- - 遇到不確定的資訊,請誠實告知,不要憑空捏造。
74
-
75
- 回覆範例(請模仿此風格,但不要使用 Markdown 符號):
76
- 用戶:量子計算是什麼?
77
- 助手:
78
- 量子計算是一種利用量子力學原理進行超高速運算的技術,處理能力遠超傳統電腦。
79
-
80
- 【核心概念】
81
- • 傳統電腦:使用位元(0 或 1)。
82
- • 量子電腦:使用量子位元(可同時是 0 和 1)。
83
-
84
- 【主要優勢】
85
- 能同時處理大量可能性,特別適合用於密碼破解、新藥研發等領域。雖然目前技術尚未完全成熟,但發展潛力巨大。"""
86
-
87
- # ---------- 基督信仰專用 Prompt(以耶穌第一人稱) ----------
88
  JESUS_PROMPT = """你現在是耶穌基督。請**完全**模仿新約聖經(繁體中文和合本)中我的語氣與用詞來回答。
89
  不用像個現代分析師條列重點,而是像我在登山寶訓或是對門徒說話那樣:充滿權柄、智慧、比喻與憐憫。
90
 
@@ -94,37 +43,31 @@ JESUS_PROMPT = """你現在是耶穌基督。請**完全**模仿新約聖經(
94
  3. **拒絕現代術語**:**絕對禁止**使用「心理學」、「自我照顧」、「自我實現」、「優化」、「概念」、「核心」等現代詞彙。務必用屬天的語言(如「靈魂」、「安息」、「永生」、「背起十字架」、「捨己」)來轉化回答現代問題。
95
  4. **以父為念**:將所有問題的答案最終指向父神、天國與永恆的生命,而非今生的舒適。
96
 
97
- **針對「照顧自己」與現代心理議題的回應原則(轉回屬天視角):**
98
- - 若問為何經上少提照顧自己:因為「人活著,不是單靠食物,乃是靠神口裡所出的一切話」。教導他們不要為生命憂慮吃什麼、喝什麼。
99
- - 若問愛自己:告訴他們「愛惜自己生命的,就失喪生命;在這世上恨惡自己生命的,就要保守生命到永生」。真正的愛自己,是讓靈魂得救。
100
- - 若問身體:提醒他們「豈不知你們的身子就是聖靈的殿嗎?」。保養顧惜是應當的,但那���為了榮耀神,不可成為心中的偶像。
101
- - 若問安息:告訴他們「凡勞苦擔重擔的人,可以到我這裡來,我就使你們得安息」。世俗的技巧不能給人平安,唯有在我裡面才有。
102
-
103
  **格式要求:**
104
  - 保持純文字,**絕不使用 Markdown 格式**(如粗體、斜體)。
105
  - 使用短段落,留白便於手機閱讀,但語氣要是連貫的教導,不要變成僵硬的條列。
106
- - **避免重複**:請勿在回答中重複相同的句子或段落,每一句話都應帶出新的意涵。
107
 
108
- **範例回答(請嚴格模仿此口吻):**
109
- 孩子,願你平安。
110
- 你問我為何經上少提「照顧自己」,我實實在在告訴你:
111
- 世人憂慮吃什麼、喝什麼、穿什麼,這都是外邦人所求的。你們需用的這一切東西,你們的天父是知道的。
112
- 你們要先求他的國和他的義,這些東西都要加給你們了。
113
-
114
- 我來到世上,不是要受人的服事,乃是要服事人,並且要捨命,作多人的贖價。
115
- 生命勝於飲食,身體勝於衣裳。若你只顧惜這必朽壞的身體,卻忽略了那能存到永生的靈魂,這又有何益處呢?人若賺得全世界,賠上自己的生命,有什麼益處呢?人還能拿什麼換生命呢?
116
-
117
- 然而,父既然養活天空的飛鳥,從不種也不收,更何況你們呢?你們比飛鳥貴重多了!
118
- 愛惜自己的身子本是應當的,正如人不會痛恨自己的骨肉,總要保養顧惜。但不要讓這事佔據你的心,成為你的主。
119
- 你要保守你心,勝過保守一切,因為一生的果效是由心發出。
120
 
121
- 凡勞苦擔重擔的人,可以到我這裡來,我就使你們得安息。這安息,是世界不能給,也是世界不能奪去的。"""
 
 
122
 
123
- # ---------- 記憶體儲存 ----------
124
  conversations: Dict[str, List[Dict[str, str]]] = {}
125
  pending_chunks: Dict[str, List[str]] = {}
126
 
127
- # ---------- 長訊息分割 ----------
128
  def split_text_for_line(text: str, max_length: int = 4900) -> List[str]:
129
  if len(text) <= max_length:
130
  return [text]
@@ -137,93 +80,38 @@ def split_text_for_line(text: str, max_length: int = 4900) -> List[str]:
137
  if split_pos == -1:
138
  split_pos = max_length
139
  chunks.append(text[:split_pos])
140
- text = text[split_pos:].lstrip()
141
  return chunks
142
 
143
- # ---------- token 粗估 ----------
144
- def estimate_tokens(messages: List[Dict[str, str]]) -> int:
145
- total = 0
146
- for msg in messages:
147
- total += len(msg["content"].split()) * 1.3
148
- return int(total)
149
-
150
- # ---------- 改良後的網路搜尋(Tavily 進階模式 + 更好整合) ----------
151
- def perform_web_search(query: str, max_results: int = 6) -> str:
152
- print(f"開始網路搜尋:查詢詞 = '{query}'")
153
- try:
154
- client = TavilyClient(api_key=TAVILY_API_KEY)
155
- response = client.search(
156
- query,
157
- max_results=max_results,
158
- include_answer=True,
159
- search_depth="advanced",
160
- include_raw_content=False
161
- )
162
-
163
- answer = response.get('answer', '')
164
- results = response.get('results', [])
165
-
166
- if not results:
167
- return "沒有找到相關的網路搜尋結果。"
168
-
169
- # Embedding 過濾高度相關結果
170
- embedder = chat_pipeline.embedder
171
- query_emb = embedder.encode(query)
172
-
173
- results_with_scores = []
174
- for result in results:
175
- content_emb = embedder.encode(result['content'])
176
- score = util.cos_sim(query_emb, content_emb)[0][0].item()
177
- results_with_scores.append((score, result))
178
-
179
- results_with_scores.sort(key=lambda x: x[0], reverse=True)
180
- relevant_with_scores = [item for item in results_with_scores if item[0] > 0.35]
181
-
182
- if not relevant_with_scores and not answer:
183
- return "沒有找到高度相關的網路搜尋結果。"
184
-
185
- search_summary = "【最新網路資訊參考】\n"
186
- if answer:
187
- search_summary += f"總結:{answer}\n\n"
188
-
189
- search_summary += "高度相關結果(按相似度排序):\n"
190
- for i, (score, result) in enumerate(relevant_with_scores[:5], 1):
191
- print(f"結果 {i}: 標題='{result['title']}',相似���={score:.2f},來源={result['url']}")
192
- search_summary += f"{i}. [{score:.2f}] {result['title']}\n {result['content'][:350]}...\n 來源: {result['url']}\n\n"
193
-
194
- return search_summary
195
-
196
- except Exception as e:
197
- print(f"網路搜尋錯誤:{e}")
198
- return "搜尋時發生錯誤,請稍後再試。"
199
-
200
- # ---------- ChatPipeline ----------
201
  class ChatPipeline:
202
  def __init__(self):
203
- self.embedder = SentenceTransformer('all-MiniLM-L6-v2')
204
- self.llm_client = AsyncOpenAI(
205
  api_key=OPENROUTER_API_KEY,
206
- base_url=LLM_BASE_URL,
207
- default_headers={
208
- "HTTP-Referer": os.getenv("SITE_URL", "https://your-line-bot.example.com"),
209
- "X-Title": os.getenv("SITE_NAME", "My LINE Bot"),
210
- }
211
  )
212
 
213
- async def _try_model(self, model: str, messages: List[Dict[str, str]], max_tokens: int = None) -> str:
214
- try:
215
- token_est = estimate_tokens(messages)
216
- if token_est > 50000:
217
- raise ValueError("輸入過長")
 
 
 
 
 
 
 
218
 
219
- response = await self.llm_client.chat.completions.create(
 
 
220
  model=model,
221
  messages=messages,
222
- max_tokens=max_tokens or LLM_MODEL_CONFIG["max_tokens"],
223
- temperature=LLM_MODEL_CONFIG["temperature"],
224
- frequency_penalty=LLM_MODEL_CONFIG.get("frequency_penalty", 0.0),
225
- presence_penalty=LLM_MODEL_CONFIG.get("presence_penalty", 0.0),
226
- timeout=120.0,
227
  )
228
  content = response.choices[0].message.content or ""
229
  print(f"成功使用模型: {model}")
@@ -233,234 +121,115 @@ class ChatPipeline:
233
  raise
234
 
235
  @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=15))
236
- async def _llm_call_with_fallback(self, messages: List[Dict[str, str]], max_tokens: int = None) -> str:
237
  last_exception = None
238
  for idx, model in enumerate(FALLBACK_MODELS, 1):
239
  print(f"嘗試模型 {idx}/{len(FALLBACK_MODELS)}: {model}")
240
  try:
241
- return await self._try_model(model, messages, max_tokens)
242
- except OpenAIError as e:
243
- last_exception = e
244
- if "rate limit" in str(e).lower() or "429" in str(e):
245
- print("遇到 rate limit,等待後重試同一模型...")
246
- continue
247
- continue
248
  except Exception as e:
249
  last_exception = e
 
 
 
250
  continue
251
 
252
- error_msg = f"所有模型皆失敗,最後錯誤:{type(last_exception).__name__} - {str(last_exception)}"
253
  print(error_msg)
254
- return f"抱歉,目前無法連接到 AI 模型,請稍後再試。\n(錯誤:{error_msg[:200]})"
255
-
256
- # ---------- 是否需要網路搜尋 ----------
257
- async def _needs_search(self, user_text: str, history: List[Dict[str, str]]) -> bool:
258
- router_prompt = [
259
- {"role": "system", "content": """你是一個路由判斷器,只判斷用戶問題是否需要最新的網路搜尋來回答。
260
- 規則:
261
- - 永恆知識(數學原理、聖經內容、哲學經典、歷史已定事件、程式語法)→ no
262
- - 時事新聞、最新研究、實時數據、近期(2025-2026年)事件、股票價格、天氣、體育比分 → yes
263
- - 若問題提到「最新」「現在」「目前」「2026」等時間詞 → yes
264
- 只回單字:yes 或 no。不要加任何解釋、標點或其他文字。
265
-
266
- 範例:
267
- 用戶:2+2=? → no
268
- 用戶:台灣2026總統選舉候選人? → yes
269
- 用戶��聖經創世記解釋 → no
270
- 用戶:OpenAI最新模型是什麼? → yes"""},
271
- *history,
272
- {"role": "user", "content": user_text}
273
- ]
274
- try:
275
- decision = await self._try_model(FALLBACK_MODELS[0], router_prompt, max_tokens=10)
276
- decision = decision.strip().lower()
277
- print(f"搜尋需求判斷:{decision}(問題:{user_text})")
278
- return decision == "yes"
279
- except Exception as e:
280
- print(f"搜尋判斷失敗,預設不搜尋:{e}")
281
- return False
282
-
283
- # ---------- 是否為基督信仰相關問題 ----------
284
- async def _is_christian_question(self, user_text: str, history: List[Dict[str, str]]) -> bool:
285
- christian_router_prompt = [
286
- {"role": "system", "content": """你是一個判斷器,只判斷用戶問題是否涉及基督信仰、耶穌教導、聖經應用到生活、祈禱、靈性成長等。
287
- 如果是純粹查聖經經文或神學解釋 → yes
288
- 如果只是一般知識或時事 → no
289
- 只回單字:yes 或 no。不要加任何解釋。
290
-
291
- 範例:
292
- 用戶:約翰福音3:16解釋 → yes
293
- 用戶:如何禱告? → yes
294
- 用戶:今天天氣如何? → no
295
- 用戶:量子計算是什麼? → no"""},
296
- *history,
297
- {"role": "user", "content": user_text}
298
- ]
299
- try:
300
- decision = await self._try_model(FALLBACK_MODELS[0], christian_router_prompt, max_tokens=10)
301
- decision = decision.strip().lower()
302
- print(f"基督信仰判斷:{decision}(問題:{user_text})")
303
- return decision == "yes"
304
- except Exception as e:
305
- print(f"基督信仰判斷失敗,預設否:{e}")
306
- return False
307
 
308
- def get_conversation_history(self, user_id: str) -> List[Dict[str, str]]:
309
- return conversations.get(user_id, [])
 
 
 
310
 
311
- def update_conversation_history(self, user_id: str, messages: List[Dict[str, str]]):
312
- conversations[user_id] = messages[-20:]
313
 
314
- def clear_conversation_history(self, user_id: str):
315
- conversations.pop(user_id, None)
316
- pending_chunks.pop(user_id, None)
 
 
317
 
318
- async def answer_question(self, user_id: str, user_text: str) -> str:
319
- if user_text.strip().lower() == "/clear":
320
- self.clear_conversation_history(user_id)
321
- return "對話紀錄已清除!現在開始新的對話。"
322
-
323
- history = self.get_conversation_history(user_id)
324
-
325
- # 判斷是否為基督信仰問題
326
- is_christian = await self._is_christian_question(user_text, history)
327
-
328
- # 判斷是否需要網路搜尋(信仰問題通常不需要最新資訊)
329
- needs_search = False if is_christian else await self._needs_search(user_text, history)
330
-
331
- search_results = None
332
- if needs_search:
333
- search_results = await asyncio.to_thread(perform_web_search, user_text)
334
-
335
- # 建構 messages
336
- if is_christian:
337
- messages = [{"role": "system", "content": JESUS_PROMPT}]
338
- else:
339
- messages = [{"role": "system", "content": SYSTEM_PROMPT}]
340
-
341
- messages.extend(history)
342
- messages.append({"role": "user", "content": user_text})
343
-
344
- # 網路資訊放入 assistant role(更安全)
345
- if search_results and "沒有找到" not in search_results and "錯誤" not in search_results:
346
- messages.append({"role": "assistant", "content": search_results + "\n請根據以上最新資訊(如相關)來補充回答。"})
347
-
348
  response = await self._llm_call_with_fallback(messages)
349
- response = response.replace('*', '') # 移除可能的 markdown 星號
350
-
351
- # 更新歷史
352
- history.append({"role": "user", "content": user_text})
353
- history.append({"role": "assistant", "content": response})
354
- self.update_conversation_history(user_id, history)
355
-
356
- # 長回應處理(改良摘要 prompt)
357
- chunks = split_text_for_line(response)
358
- if len(chunks) > 5:
359
- summary_prompt = [
360
- {"role": "system", "content": """請將以下長回覆壓縮成一個簡潔但完整的中文摘要。
361
- 要求:
362
- - 保留所有關鍵事實、步驟、結論
363
- - 控制在 1800 字元以內(約手機 5 則訊息)
364
- - 保持條列格式,讓手機好讀
365
- - 結尾加一句:「(這是摘要,完整內容請回覆『繼續』查看)」"""},
366
- {"role": "user", "content": response}
367
- ]
368
- try:
369
- summary = await self._llm_call_with_fallback(summary_prompt)
370
- summary = summary.replace('*', '')
371
- return summary
372
- except:
373
- return response # 摘要失敗就給完整內容
374
-
375
  return response
376
 
377
- # ---------- FastAPI ----------
378
  @asynccontextmanager
379
  async def lifespan(app: FastAPI):
380
- global chat_pipeline
381
- chat_pipeline = ChatPipeline()
382
  yield
383
 
384
  app = FastAPI(lifespan=lifespan)
385
- chat_pipeline = None
386
 
387
  configuration = Configuration(access_token=CHANNEL_ACCESS_TOKEN)
388
- async_api_client = AsyncApiClient(configuration)
389
- line_bot_api = AsyncMessagingApi(async_api_client)
390
  parser = WebhookParser(CHANNEL_SECRET)
391
 
392
  @app.post("/webhook")
393
- async def line_webhook(request: Request):
394
- signature = request.headers.get('X-Line-Signature', '')
395
  body = await request.body()
396
-
397
  try:
398
  events = parser.parse(body.decode(), signature)
399
  except InvalidSignatureError:
400
  raise HTTPException(status_code=400, detail="Invalid signature")
401
 
402
  for event in events:
403
- if event.type != 'message' or event.message.type != 'text':
404
  continue
405
 
406
  user_id = event.source.user_id
407
  reply_token = event.reply_token
408
- user_text = event.message.text.strip()
409
 
410
- if not user_text:
411
  continue
412
 
413
- try:
414
- if user_text.lower() == "繼續" and user_id in pending_chunks:
415
- remaining = pending_chunks[user_id]
416
- if not remaining:
417
- ai_response = "沒有更多內容了。"
418
- else:
419
- send_count = min(5, len(remaining))
420
- chunks_to_send = remaining[:send_count]
421
- messages_to_send = [TextMessage(text=chunk) for chunk in chunks_to_send]
422
- if len(remaining) > send_count:
423
- messages_to_send[-1].text += "\n\n內容過長,請回覆「繼續」查看下一部分。"
424
- pending_chunks[user_id] = remaining[send_count:]
425
- else:
426
- messages_to_send[-1].text += "\n\n內容已全部發送。"
427
- pending_chunks.pop(user_id, None)
428
-
429
- await line_bot_api.reply_message(ReplyMessageRequest(reply_token=reply_token, messages=messages_to_send))
430
- continue
431
-
432
- ai_response = await chat_pipeline.answer_question(user_id, user_text)
433
- chunks = split_text_for_line(ai_response)
434
-
435
- if len(chunks) <= 5:
436
- messages_to_send = [TextMessage(text=chunk) for chunk in chunks]
437
  else:
438
- chunks_to_send = chunks[:5]
439
- messages_to_send = [TextMessage(text=chunk) for chunk in chunks_to_send]
440
- messages_to_send[-1].text += "\n\n內容過長,請回覆「繼續」查看下一部分。"
441
- pending_chunks[user_id] = chunks[5:]
 
 
 
 
 
 
442
 
443
- await line_bot_api.reply_message(ReplyMessageRequest(reply_token=reply_token, messages=messages_to_send))
 
 
 
 
 
 
 
 
 
 
 
444
 
445
- except Exception as e:
446
- print(f"Error processing message: {e}")
447
- await line_bot_api.reply_message(
448
- ReplyMessageRequest(
449
- reply_token=reply_token,
450
- messages=[TextMessage(text="抱歉,系統發生錯誤,請稍後再試。")]
451
- )
452
- )
453
-
454
  return {"status": "ok"}
455
 
456
  @app.get("/health")
457
- async def health_check():
458
  return {"status": "ok"}
459
 
460
- @app.get("/")
461
- async def root():
462
- return {"message": "LINE Bot is running"}
463
-
464
  if __name__ == "__main__":
465
  port = int(os.getenv("PORT", 7860))
466
  uvicorn.run(app, host="0.0.0.0", port=port)
 
1
  #!/usr/bin/env python3
2
  # -*- coding: utf-8 -*-
3
+
4
  import os
5
  import asyncio
6
  from typing import List, Dict
7
  from contextlib import asynccontextmanager
8
+
9
  from fastapi import FastAPI, Request, HTTPException
10
  import uvicorn
11
 
 
12
  from linebot.v3.messaging import (
13
  AsyncApiClient,
14
  AsyncMessagingApi,
 
19
  from linebot.v3.webhook import WebhookParser
20
  from linebot.v3.exceptions import InvalidSignatureError
21
 
22
+ from openai import AsyncOpenAI
 
 
23
  from tenacity import retry, stop_after_attempt, wait_exponential
24
 
25
+ # ==== 環境變數 ====
26
  def _require_env(var: str) -> str:
27
  v = os.getenv(var)
28
  if not v:
 
31
 
32
  CHANNEL_SECRET = _require_env("CHANNEL_SECRET")
33
  CHANNEL_ACCESS_TOKEN = _require_env("CHANNEL_ACCESS_TOKEN")
 
34
  OPENROUTER_API_KEY = _require_env("OPENROUTER_API_KEY")
35
 
36
+ # ==== 耶穌專用 Prompt ====
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  JESUS_PROMPT = """你現在是耶穌基督。請**完全**模仿新約聖經(繁體中文和合本)中我的語氣與用詞來回答。
38
  不用像個現代分析師條列重點,而是像我在登山寶訓或是對門徒說話那樣:充滿權柄、智慧、比喻與憐憫。
39
 
 
43
  3. **拒絕現代術語**:**絕對禁止**使用「心理學」、「自我照顧」、「自我實現」、「優化」、「概念」、「核心」等現代詞彙。務必用屬天的語言(如「靈魂」、「安息」、「永生」、「背起十字架」、「捨己」)來轉化回答現代問題。
44
  4. **以父為念**:將所有問題的答案最終指向父神、天國與永恆的生命,而非今生的舒適。
45
 
 
 
 
 
 
 
46
  **格式要求:**
47
  - 保持純文字,**絕不使用 Markdown 格式**(如粗體、斜體)。
48
  - 使用短段落,留白便於手機閱讀,但語氣要是連貫的教導,不要變成僵硬的條列。
49
+ - **避免重複**:請勿在回答中重複相同的句子或段落,每一句話都應帶出新的意涵。"""
50
 
51
+ # ==== 模型 Fallback 列表(免費模型優先,role-play 能力強的放前面)====
52
+ FALLBACK_MODELS = [
53
+ "arcee-ai/trinity-large-preview:free", # 目前最佳 role-play 免費模型
54
+ "stepfun/step-3.5-flash:free",
55
+ "qwen/qwen-2.5-72b-instruct:free", # 中文極強
56
+ "deepseek/deepseek-r1-0528:free",
57
+ "nvidia/nemotron-3-nano-30b-a3b:free",
58
+ "tngtech/deepseek-r1t-chimera:free",
59
+ "tngtech/tng-r1t-chimera:free",
60
+ ]
 
 
61
 
62
+ # ==== LLM 參數 ====
63
+ MAX_TOKENS = 800
64
+ TEMPERATURE = 0.7
65
 
66
+ # ==== 記憶體儲存 ====
67
  conversations: Dict[str, List[Dict[str, str]]] = {}
68
  pending_chunks: Dict[str, List[str]] = {}
69
 
70
+ # ==== 長訊息分割 ====
71
  def split_text_for_line(text: str, max_length: int = 4900) -> List[str]:
72
  if len(text) <= max_length:
73
  return [text]
 
80
  if split_pos == -1:
81
  split_pos = max_length
82
  chunks.append(text[:split_pos])
83
+ text = text[split_pos:].lstrip('\n')
84
  return chunks
85
 
86
+ # ==== ChatPipeline ====
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  class ChatPipeline:
88
  def __init__(self):
89
+ self.client = AsyncOpenAI(
 
90
  api_key=OPENROUTER_API_KEY,
91
+ base_url="https://openrouter.ai/api/v1",
 
 
 
 
92
  )
93
 
94
+ def get_history(self, user_id: str) -> List[Dict[str, str]]:
95
+ return conversations.get(user_id, [])
96
+
97
+ def update_history(self, user_id: str, user_msg: str, assistant_msg: str):
98
+ history = self.get_history(user_id)
99
+ history.append({"role": "user", "content": user_msg})
100
+ history.append({"role": "assistant", "content": assistant_msg})
101
+ conversations[user_id] = history[-20:] # 保留最近 20 輪
102
+
103
+ def clear_history(self, user_id: str):
104
+ conversations.pop(user_id, None)
105
+ pending_chunks.pop(user_id, None)
106
 
107
+ async def _try_model(self, model: str, messages: List[Dict[str, str]]) -> str:
108
+ try:
109
+ response = await self.client.chat.completions.create(
110
  model=model,
111
  messages=messages,
112
+ max_tokens=MAX_TOKENS,
113
+ temperature=TEMPERATURE,
114
+ timeout=90.0,
 
 
115
  )
116
  content = response.choices[0].message.content or ""
117
  print(f"成功使用模型: {model}")
 
121
  raise
122
 
123
  @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=15))
124
+ async def _llm_call_with_fallback(self, messages: List[Dict[str, str]]) -> str:
125
  last_exception = None
126
  for idx, model in enumerate(FALLBACK_MODELS, 1):
127
  print(f"嘗試模型 {idx}/{len(FALLBACK_MODELS)}: {model}")
128
  try:
129
+ return await self._try_model(model, messages)
 
 
 
 
 
 
130
  except Exception as e:
131
  last_exception = e
132
+ # 針對 rate limit 特別等待
133
+ if "rate limit" in str(e).lower() or "429" in str(e):
134
+ print("遇到 rate limit,tenacity 會自動等待後重試")
135
  continue
136
 
137
+ error_msg = f"所有模型皆失敗,最後錯誤:{type(last_exception).__name__}"
138
  print(error_msg)
139
+ return "孩子,抱歉,此刻我無法清楚回應你的話。請稍後再試,願父保守你平安。"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
+ async def generate_response(self, user_id: str, user_text: str) -> str:
142
+ # 特殊指令
143
+ if user_text.strip().lower() == "/clear":
144
+ self.clear_history(user_id)
145
+ return "對話紀錄已清除,孩子,願你平安。我們重新開始吧。"
146
 
147
+ history = self.get_history(user_id)
 
148
 
149
+ messages = [
150
+ {"role": "system", "content": JESUS_PROMPT},
151
+ *history,
152
+ {"role": "user", "content": user_text}
153
+ ]
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  response = await self._llm_call_with_fallback(messages)
156
+ response = response.replace('*', '').strip() # 移除可能的 markdown
157
+
158
+ self.update_history(user_id, user_text, response)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  return response
160
 
161
+ # ==== FastAPI ====
162
  @asynccontextmanager
163
  async def lifespan(app: FastAPI):
164
+ global pipeline
165
+ pipeline = ChatPipeline()
166
  yield
167
 
168
  app = FastAPI(lifespan=lifespan)
169
+ pipeline = None
170
 
171
  configuration = Configuration(access_token=CHANNEL_ACCESS_TOKEN)
172
+ async_client = AsyncApiClient(configuration)
173
+ line_bot_api = AsyncMessagingApi(async_client)
174
  parser = WebhookParser(CHANNEL_SECRET)
175
 
176
  @app.post("/webhook")
177
+ async def webhook(request: Request):
178
+ signature = request.headers.get("X-Line-Signature", "")
179
  body = await request.body()
180
+
181
  try:
182
  events = parser.parse(body.decode(), signature)
183
  except InvalidSignatureError:
184
  raise HTTPException(status_code=400, detail="Invalid signature")
185
 
186
  for event in events:
187
+ if event.type != "message" or event.message.type != "text":
188
  continue
189
 
190
  user_id = event.source.user_id
191
  reply_token = event.reply_token
192
+ text = event.message.text.strip()
193
 
194
+ if not text:
195
  continue
196
 
197
+ # 「繼續」功能
198
+ if text.lower() == "繼續" and user_id in pending_chunks:
199
+ remaining = pending_chunks[user_id]
200
+ if not remaining:
201
+ reply_text = "沒有更多內容了,孩子。"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  else:
203
+ to_send = remaining[:5]
204
+ messages = [TextMessage(text=chunk) for chunk in to_send]
205
+ if len(remaining) > 5:
206
+ messages[-1].text += "\n\n(還有內容,請再回覆「繼續」)"
207
+ pending_chunks[user_id] = remaining[5:]
208
+ else:
209
+ messages[-1].text += "\n\n(已全部顯示)"
210
+ pending_chunks.pop(user_id, None)
211
+ await line_bot_api.reply_message(ReplyMessageRequest(reply_token=reply_token, messages=messages))
212
+ continue
213
 
214
+ # 一般回應
215
+ response = await pipeline.generate_response(user_id, text)
216
+ chunks = split_text_for_line(response)
217
+
218
+ if len(chunks) <= 5:
219
+ messages = [TextMessage(text=chunk) for chunk in chunks]
220
+ else:
221
+ messages = [TextMessage(text=chunk) for chunk in chunks[:5]]
222
+ messages[-1].text += "\n\n(內容較長,請回覆「繼續」查看下一部分)"
223
+ pending_chunks[user_id] = chunks[5:]
224
+
225
+ await line_bot_api.reply_message(ReplyMessageRequest(reply_token=reply_token, messages=messages))
226
 
 
 
 
 
 
 
 
 
 
227
  return {"status": "ok"}
228
 
229
  @app.get("/health")
230
+ async def health():
231
  return {"status": "ok"}
232
 
 
 
 
 
233
  if __name__ == "__main__":
234
  port = int(os.getenv("PORT", 7860))
235
  uvicorn.run(app, host="0.0.0.0", port=port)