botrun-flow-lang 5.9.301__py3-none-any.whl → 5.10.82__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. botrun_flow_lang/api/auth_api.py +39 -39
  2. botrun_flow_lang/api/auth_utils.py +183 -183
  3. botrun_flow_lang/api/botrun_back_api.py +65 -65
  4. botrun_flow_lang/api/flow_api.py +3 -3
  5. botrun_flow_lang/api/hatch_api.py +481 -481
  6. botrun_flow_lang/api/langgraph_api.py +796 -796
  7. botrun_flow_lang/api/line_bot_api.py +1357 -1357
  8. botrun_flow_lang/api/model_api.py +300 -300
  9. botrun_flow_lang/api/rate_limit_api.py +32 -32
  10. botrun_flow_lang/api/routes.py +79 -79
  11. botrun_flow_lang/api/search_api.py +53 -53
  12. botrun_flow_lang/api/storage_api.py +316 -316
  13. botrun_flow_lang/api/subsidy_api.py +290 -290
  14. botrun_flow_lang/api/subsidy_api_system_prompt.txt +109 -109
  15. botrun_flow_lang/api/user_setting_api.py +70 -70
  16. botrun_flow_lang/api/version_api.py +31 -31
  17. botrun_flow_lang/api/youtube_api.py +26 -26
  18. botrun_flow_lang/constants.py +13 -13
  19. botrun_flow_lang/langgraph_agents/agents/agent_runner.py +174 -174
  20. botrun_flow_lang/langgraph_agents/agents/agent_tools/step_planner.py +77 -77
  21. botrun_flow_lang/langgraph_agents/agents/checkpointer/firestore_checkpointer.py +666 -666
  22. botrun_flow_lang/langgraph_agents/agents/gov_researcher/GOV_RESEARCHER_PRD.md +192 -192
  23. botrun_flow_lang/langgraph_agents/agents/gov_researcher/gov_researcher_2_graph.py +1002 -1002
  24. botrun_flow_lang/langgraph_agents/agents/gov_researcher/gov_researcher_graph.py +822 -822
  25. botrun_flow_lang/langgraph_agents/agents/langgraph_react_agent.py +548 -542
  26. botrun_flow_lang/langgraph_agents/agents/search_agent_graph.py +864 -864
  27. botrun_flow_lang/langgraph_agents/agents/tools/__init__.py +4 -4
  28. botrun_flow_lang/langgraph_agents/agents/tools/gemini_code_execution.py +376 -376
  29. botrun_flow_lang/langgraph_agents/agents/util/gemini_grounding.py +66 -66
  30. botrun_flow_lang/langgraph_agents/agents/util/html_util.py +316 -316
  31. botrun_flow_lang/langgraph_agents/agents/util/img_util.py +294 -294
  32. botrun_flow_lang/langgraph_agents/agents/util/local_files.py +345 -345
  33. botrun_flow_lang/langgraph_agents/agents/util/mermaid_util.py +86 -86
  34. botrun_flow_lang/langgraph_agents/agents/util/model_utils.py +143 -143
  35. botrun_flow_lang/langgraph_agents/agents/util/pdf_analyzer.py +160 -160
  36. botrun_flow_lang/langgraph_agents/agents/util/perplexity_search.py +464 -464
  37. botrun_flow_lang/langgraph_agents/agents/util/plotly_util.py +59 -59
  38. botrun_flow_lang/langgraph_agents/agents/util/tavily_search.py +199 -199
  39. botrun_flow_lang/langgraph_agents/agents/util/youtube_util.py +90 -90
  40. botrun_flow_lang/langgraph_agents/cache/langgraph_botrun_cache.py +197 -197
  41. botrun_flow_lang/llm_agent/llm_agent.py +19 -19
  42. botrun_flow_lang/llm_agent/llm_agent_util.py +83 -83
  43. botrun_flow_lang/log/.gitignore +2 -2
  44. botrun_flow_lang/main.py +61 -61
  45. botrun_flow_lang/main_fast.py +51 -51
  46. botrun_flow_lang/mcp_server/__init__.py +10 -10
  47. botrun_flow_lang/mcp_server/default_mcp.py +711 -711
  48. botrun_flow_lang/models/nodes/utils.py +205 -205
  49. botrun_flow_lang/models/token_usage.py +34 -34
  50. botrun_flow_lang/requirements.txt +21 -21
  51. botrun_flow_lang/services/base/firestore_base.py +30 -30
  52. botrun_flow_lang/services/hatch/hatch_factory.py +11 -11
  53. botrun_flow_lang/services/hatch/hatch_fs_store.py +372 -372
  54. botrun_flow_lang/services/storage/storage_cs_store.py +202 -202
  55. botrun_flow_lang/services/storage/storage_factory.py +12 -12
  56. botrun_flow_lang/services/storage/storage_store.py +65 -65
  57. botrun_flow_lang/services/user_setting/user_setting_factory.py +9 -9
  58. botrun_flow_lang/services/user_setting/user_setting_fs_store.py +66 -66
  59. botrun_flow_lang/static/docs/tools/index.html +926 -926
  60. botrun_flow_lang/tests/api_functional_tests.py +1525 -1525
  61. botrun_flow_lang/tests/api_stress_test.py +357 -357
  62. botrun_flow_lang/tests/shared_hatch_tests.py +333 -333
  63. botrun_flow_lang/tests/test_botrun_app.py +46 -46
  64. botrun_flow_lang/tests/test_html_util.py +31 -31
  65. botrun_flow_lang/tests/test_img_analyzer.py +190 -190
  66. botrun_flow_lang/tests/test_img_util.py +39 -39
  67. botrun_flow_lang/tests/test_local_files.py +114 -114
  68. botrun_flow_lang/tests/test_mermaid_util.py +103 -103
  69. botrun_flow_lang/tests/test_pdf_analyzer.py +104 -104
  70. botrun_flow_lang/tests/test_plotly_util.py +151 -151
  71. botrun_flow_lang/tests/test_run_workflow_engine.py +65 -65
  72. botrun_flow_lang/tools/generate_docs.py +133 -133
  73. botrun_flow_lang/tools/templates/tools.html +153 -153
  74. botrun_flow_lang/utils/__init__.py +7 -7
  75. botrun_flow_lang/utils/botrun_logger.py +344 -344
  76. botrun_flow_lang/utils/clients/rate_limit_client.py +209 -209
  77. botrun_flow_lang/utils/clients/token_verify_client.py +153 -153
  78. botrun_flow_lang/utils/google_drive_utils.py +654 -654
  79. botrun_flow_lang/utils/langchain_utils.py +324 -324
  80. botrun_flow_lang/utils/yaml_utils.py +9 -9
  81. {botrun_flow_lang-5.9.301.dist-info → botrun_flow_lang-5.10.82.dist-info}/METADATA +2 -2
  82. botrun_flow_lang-5.10.82.dist-info/RECORD +99 -0
  83. botrun_flow_lang-5.9.301.dist-info/RECORD +0 -99
  84. {botrun_flow_lang-5.9.301.dist-info → botrun_flow_lang-5.10.82.dist-info}/WHEEL +0 -0
@@ -1,1357 +1,1357 @@
1
- import os
2
- import json
3
- import time
4
- import sys
5
- import logging
6
- import traceback
7
- import asyncio
8
- import aiohttp
9
- from collections import defaultdict, deque
10
- from typing import Tuple
11
- from pathlib import Path
12
- from datetime import datetime
13
- import pytz
14
-
15
- from fastapi import APIRouter, HTTPException, Request, Depends
16
- from linebot.v3.webhooks import MessageEvent, TextMessageContent, PostbackEvent
17
- from linebot.v3.messaging import AsyncMessagingApi
18
- from pydantic import BaseModel
19
- from botrun_log import Logger, TextLogEntry
20
-
21
- from botrun_flow_lang.langgraph_agents.agents.agent_runner import (
22
- agent_runner,
23
- ChatModelEndEvent,
24
- OnNodeStreamEvent,
25
- )
26
- from botrun_flow_lang.langgraph_agents.agents.search_agent_graph import (
27
- DEFAULT_RELATED_PROMPT,
28
- NORMAL_CHAT_PROMPT_TEXT,
29
- REQUIREMENT_PROMPT_TEMPLATE,
30
- SearchAgentGraph,
31
- DEFAULT_SEARCH_CONFIG,
32
- DEFAULT_MODEL_NAME,
33
- )
34
- from botrun_flow_lang.langgraph_agents.agents.checkpointer.firestore_checkpointer import (
35
- AsyncFirestoreCheckpointer,
36
- )
37
- from botrun_flow_lang.utils.google_drive_utils import (
38
- authenticate_google_services,
39
- get_google_doc_mime_type,
40
- get_google_doc_content_with_service,
41
- create_sheet_if_not_exists,
42
- append_data_to_gsheet,
43
- get_sheet_content,
44
- )
45
- from botrun_flow_lang.api.auth_utils import verify_token
46
-
47
-
48
- # 同時輸出到螢幕與本地 log 檔
49
- LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
50
-
51
- # 建立 handlers 清單供 basicConfig 使用
52
- _console_handler = logging.StreamHandler(sys.stdout)
53
- _console_handler.setFormatter(logging.Formatter(LOG_FORMAT))
54
-
55
- handlers = [_console_handler]
56
-
57
- # 透過環境變數 `IS_WRITE_LOG_TO_FILE` 決定是否寫入本地檔案
58
- IS_WRITE_LOG_TO_FILE = os.getenv("IS_WRITE_LOG_TO_FILE", "false")
59
- if IS_WRITE_LOG_TO_FILE == "true":
60
- default_log_path = Path.cwd() / "logs" / "app.log"
61
- log_file_path = Path(os.getenv("LINE_BOT_LOG_FILE", default_log_path))
62
- log_file_path.parent.mkdir(parents=True, exist_ok=True)
63
-
64
- _file_handler = logging.FileHandler(log_file_path, encoding="utf-8")
65
- _file_handler.setFormatter(logging.Formatter(LOG_FORMAT))
66
- handlers.append(_file_handler)
67
-
68
- # 使用 basicConfig 重新配置 root logger;force=True 可覆蓋先前設定 (Py≥3.8)
69
- logging.basicConfig(
70
- level=logging.INFO, format=LOG_FORMAT, handlers=handlers, force=True
71
- )
72
-
73
- # 取得 module logger(會自動享有 root handlers)
74
- # 如需調整本模組層級,可另行設定,但通常保持 INFO 即可。
75
- logger = logging.getLogger(__name__)
76
-
77
- # 常量定義
78
- SUBSIDY_LINE_BOT_CHANNEL_SECRET = os.getenv("SUBSIDY_LINE_BOT_CHANNEL_SECRET", None)
79
- SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN = os.getenv(
80
- "SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN", None
81
- )
82
- RATE_LIMIT_WINDOW = int(
83
- os.environ.get("SUBSIDY_LINEBOT_RATE_LIMIT_WINDOW", 60)
84
- ) # 預設時間窗口為 1 分鐘 (60 秒)
85
- RATE_LIMIT_COUNT = int(
86
- os.environ.get("SUBSIDY_LINEBOT_RATE_LIMIT_COUNT", 2)
87
- ) # 預設在時間窗口內允許的訊息數量 2
88
- LINE_MAX_MESSAGE_LENGTH = 5000
89
-
90
- # Botrun API 相關環境變數
91
- BOTRUN_BACK_API_BASE = os.getenv("BOTRUN_BACK_API_BASE", None)
92
- BOTRUN_BACK_LINE_AUTH_API_TOKEN = os.getenv("BOTRUN_BACK_LINE_AUTH_API_TOKEN", None)
93
- SUBSIDY_LINE_BOT_BOTRUN_ID = os.getenv("SUBSIDY_LINE_BOT_BOTRUN_ID", "波津貼.botrun")
94
- SUBSIDY_LINE_BOT_JWT_TOKEN_HOURS = int(
95
- os.getenv("SUBSIDY_LINE_BOT_JWT_TOKEN_HOURS", "2")
96
- )
97
- SUBSIDY_LINE_BOT_USER_ROLE = os.getenv("SUBSIDY_LINE_BOT_USER_ROLE", "member")
98
- BOTRUN_FRONT_URL = os.getenv("BOTRUN_FRONT_URL", None)
99
-
100
- # 全局變數
101
- # 用於追蹤正在處理訊息的使用者,避免同一使用者同時發送多條訊息造成處理衝突
102
- _processing_users = set()
103
- # 用於訊息頻率限制:追蹤每個使用者在時間窗口內發送的訊息時間戳記
104
- # 使用 defaultdict(deque) 結構確保:1) 只記錄有發送訊息的使用者 2) 高效管理時間窗口內的訊息
105
- _user_message_timestamps = defaultdict(deque)
106
-
107
- # 初始化 subsidy_line_bot BigQuery Logger
108
- try:
109
- subsidy_line_bot_bq_logger = Logger(
110
- db_type="bigquery",
111
- department=os.getenv("BOTRUN_LOG_DEPARTMENT", "subsidy_line_bot"),
112
- credentials_path=os.getenv(
113
- "BOTRUN_LOG_CREDENTIALS_PATH",
114
- "/app/botrun_flow_lang/keys/scoop-386004-e9c7b6084fb4.json",
115
- ),
116
- project_id=os.getenv("BOTRUN_LOG_PROJECT_ID", "scoop-386004"),
117
- dataset_name=os.getenv("BOTRUN_LOG_DATASET_NAME", "subsidy_line_bot"),
118
- )
119
- except Exception as e:
120
- pass
121
-
122
-
123
- # 初始化 FastAPI 路由器,設定 API 路徑前綴
124
- router = APIRouter(prefix="/line_bot")
125
-
126
- # 必要環境變數檢查
127
- # 這裡先拿掉
128
- # if SUBSIDY_LINE_BOT_CHANNEL_SECRET is None:
129
- # print("Specify SUBSIDY_LINE_BOT_CHANNEL_SECRET as environment variable.")
130
- # sys.exit(1)
131
- # if SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN is None:
132
- # print("Specify SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN as environment variable.")
133
- # sys.exit(1)
134
-
135
-
136
- async def log_to_bigquery(
137
- user_id: str,
138
- display_name: str,
139
- action_type: str,
140
- message: str,
141
- model: str,
142
- request: Request,
143
- resource_id: str = "",
144
- ):
145
- """
146
- 使用 Botrun Logger 記錄訊息到 BigQuery
147
-
148
- Args:
149
- user_id (str): LINE 使用者 ID
150
- display_name (str): 使用者 Line 顯示名稱
151
- action_type (str): 事件類型
152
- message (str): 訊息內容
153
- model (str): 使用的模型
154
- request (Request): FastAPI request 物件,用於取得 IP 等資訊
155
- resource_id (str): 資源 ID 預設為空字串
156
- """
157
- start_time = time.time()
158
-
159
- try:
160
- # 取得 Line Server IP 位址
161
- line_server_ip = request.client.host
162
- tz = pytz.timezone("Asia/Taipei")
163
-
164
- # 建立文字記錄項目
165
- text_log = TextLogEntry(
166
- timestamp=datetime.now(tz).strftime("%Y-%m-%dT%H:%M:%SZ"),
167
- domain_name=os.getenv("DOMAIN_NAME", ""),
168
- user_department=os.getenv("BOTRUN_LOG_DEPARTMENT", "subsidy_line_bot"),
169
- user_name=f"{display_name} ({user_id})",
170
- source_ip=f"{line_server_ip} (Line Server)",
171
- session_id="",
172
- action_type=action_type,
173
- developer="subsidy_line_bot_elan",
174
- action_details=message,
175
- model=model,
176
- botrun="subsidy_line_bot",
177
- user_agent="",
178
- resource_id=resource_id,
179
- )
180
-
181
- # 插入到 BigQuery
182
- subsidy_line_bot_bq_logger.insert_text_log(text_log)
183
-
184
- elapsed_time = time.time() - start_time
185
- logging.info(
186
- f"[BigQuery Logger] 記錄使用者 {display_name} ({user_id}) 的 {action_type} 訊息到 BigQuery 成功,耗時 {elapsed_time:.3f}s"
187
- )
188
-
189
- except Exception as e:
190
- elapsed_time = time.time() - start_time
191
- logging.error(
192
- f"[BigQuery Logger] 記錄使用者 {display_name} ({user_id}) 的 {action_type} 訊息到 BigQuery 失敗,耗時 {elapsed_time:.3f}s,錯誤: {e}"
193
- )
194
-
195
-
196
- def get_prompt_from_google_doc(tag_name: str, fallback_prompt: str = ""):
197
- """
198
- 從 Google 文件中提取指定標籤的內容
199
- 優先從 Google 文件讀取,失敗時回退到指定的 fallback prompt
200
-
201
- Args:
202
- tag_name (str): 要搜尋的 XML 標籤名稱 (例如: 'system_prompt', 'related_prompt')
203
- fallback_prompt (str, optional): 當從 Google 文件讀取失敗時使用的回退內容
204
-
205
- Returns:
206
- str: 提取的內容或回退內容
207
- """
208
- try:
209
- # 檢查必要的環境變數是否存在
210
- credentials_path = os.getenv("GOOGLE_APPLICATION_CREDENTIALS_FOR_BOTRUN_DOC")
211
- file_id = os.getenv("SUBSIDY_BOTRUN_DOC_FILE_ID")
212
-
213
- if not credentials_path or not file_id:
214
- raise ValueError("Missing required environment variables")
215
-
216
- # 嘗試從 Google 文件讀取
217
- drive_service, docs_service = authenticate_google_services(credentials_path)
218
- mime_type = get_google_doc_mime_type(file_id, drive_service)
219
- file_text = get_google_doc_content_with_service(
220
- file_id, mime_type, drive_service, with_decode=True
221
- )
222
-
223
- # 提取指定標籤的內容
224
- import re
225
-
226
- pattern = f"<{tag_name}>(.*?)</{tag_name}>"
227
- match = re.search(pattern, file_text, re.DOTALL)
228
- if match:
229
- logger.info(
230
- f"[Line Bot Webhook: subsidy_webhook] Successfully extracted {tag_name} from Google Docs"
231
- )
232
- if match.group(1).strip():
233
- return match.group(1).strip()
234
- else:
235
- return fallback_prompt
236
- logger.info(
237
- f"[Line Bot Webhook: subsidy_webhook] Failed to extract {tag_name} from Google Docs, return file text"
238
- )
239
-
240
- return fallback_prompt
241
-
242
- except Exception as e:
243
- logger.warning(
244
- f"[Line Bot Webhook: subsidy_webhook] Failed to load {tag_name} from Google Docs, using fallback. Error: {e}"
245
- )
246
-
247
- return fallback_prompt
248
-
249
-
250
- def get_subsidy_api_system_prompt():
251
- """
252
- 取得智津貼的系統提示
253
- 優先從 Google 文件讀取,失敗時回退到本地檔案
254
- """
255
- current_dir = Path(__file__).parent
256
- fallback_prompt = (current_dir / "subsidy_api_system_prompt.txt").read_text(
257
- encoding="utf-8"
258
- )
259
- return get_prompt_from_google_doc("system_prompt", fallback_prompt)
260
-
261
-
262
- def get_subsidy_bot_related_prompt():
263
- """
264
- 取得智津貼的相關問題提示
265
- 優先從 Google 文件讀取,失敗時使用預設的相關問題提示
266
- """
267
- return get_prompt_from_google_doc("related_prompt", DEFAULT_RELATED_PROMPT)
268
-
269
-
270
- def get_subsidy_bot_normal_chat_prompt():
271
- """
272
- 取得智津貼的正常聊天提示
273
- 優先從 Google 文件讀取,失敗時使用預設的正常聊天提示
274
- """
275
- return get_prompt_from_google_doc("normal_chat_prompt", NORMAL_CHAT_PROMPT_TEXT)
276
-
277
-
278
- def get_subsidy_bot_requirement_prompt():
279
- """
280
- 取得智津貼的 requirement_prompt
281
- 優先從 Google 文件讀取,失敗時使用預設的必要提示
282
- """
283
- return get_prompt_from_google_doc("requirement_prompt", REQUIREMENT_PROMPT_TEMPLATE)
284
-
285
-
286
- def get_subsidy_bot_search_config() -> dict:
287
- return {
288
- **DEFAULT_SEARCH_CONFIG,
289
- "requirement_prompt": get_subsidy_bot_requirement_prompt(),
290
- "search_prompt": get_subsidy_api_system_prompt(),
291
- "normal_chat_prompt": get_subsidy_bot_normal_chat_prompt(),
292
- "related_prompt": get_subsidy_bot_related_prompt(),
293
- "domain_filter": ["*.gov.tw", "-*.gov.cn"],
294
- "user_prompt_prefix": "你是台灣人,你不可以講中國用語也不可以用簡體中文,禁止!你的回答內容不要用Markdown格式。",
295
- "stream": False,
296
- }
297
-
298
-
299
- async def create_botrun_url_to_feedback(event):
300
- """
301
- 建立 Botrun URL 以供使用者點擊進行問答
302
-
303
- Args:
304
- event: LINE Bot MessageEvent
305
-
306
- Returns:
307
- str: Botrun 前端 URL 包含 JWT token
308
-
309
- Raises:
310
- HTTPException: 當環境變數未設定或 API 呼叫失敗時
311
- """
312
- logging.info(f"[create_botrun_url_to_feedback] Start creating botrun url")
313
-
314
- # 檢查必要的環境變數
315
- if not BOTRUN_FRONT_URL:
316
- error_msg = "BOTRUN_FRONT_URL environment variable is not set"
317
- logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
318
- raise HTTPException(status_code=500, detail=error_msg)
319
-
320
- if not BOTRUN_BACK_API_BASE:
321
- error_msg = "BOTRUN_BACK_API_BASE environment variable is not set"
322
- logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
323
- raise HTTPException(status_code=500, detail=error_msg)
324
-
325
- if not BOTRUN_BACK_LINE_AUTH_API_TOKEN:
326
- error_msg = "BOTRUN_BACK_LINE_AUTH_API_TOKEN environment variable is not set"
327
- logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
328
- raise HTTPException(status_code=500, detail=error_msg)
329
-
330
- # 組合 API URL
331
- api_url = f"{BOTRUN_BACK_API_BASE}/botrun/v2/line/auth/token"
332
-
333
- # 準備請求參數
334
- headers = {
335
- "accept": "application/json",
336
- "x-api-token": BOTRUN_BACK_LINE_AUTH_API_TOKEN,
337
- "Content-Type": "application/json"
338
- }
339
-
340
- payload = {
341
- "botrun_id": SUBSIDY_LINE_BOT_BOTRUN_ID,
342
- "message": event.message.text,
343
- "token_hours": SUBSIDY_LINE_BOT_JWT_TOKEN_HOURS,
344
- "user_role": SUBSIDY_LINE_BOT_USER_ROLE,
345
- "username": event.source.user_id
346
- }
347
-
348
- logging.info(f"[create_botrun_url_to_feedback] Calling API: {api_url}")
349
- logging.info(f"[create_botrun_url_to_feedback] Payload: botrun_id={payload['botrun_id']}, "
350
- f"token_hours={payload['token_hours']}, user_role={payload['user_role']}, "
351
- f"username={payload['username']}")
352
-
353
- try:
354
- async with aiohttp.ClientSession() as session:
355
- async with session.post(api_url, headers=headers, json=payload) as response:
356
- response_text = await response.text()
357
-
358
- if response.status != 200:
359
- error_msg = f"API returned status {response.status}: {response_text}"
360
- logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
361
- raise HTTPException(
362
- status_code=500,
363
- detail=f"Failed to get authentication token from Botrun API"
364
- )
365
-
366
- try:
367
- response_data = json.loads(response_text)
368
- except json.JSONDecodeError:
369
- error_msg = f"Invalid JSON response: {response_text}"
370
- logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
371
- raise HTTPException(
372
- status_code=500,
373
- detail="Invalid response format from Botrun API"
374
- )
375
-
376
- # 檢查 API 回應是否成功
377
- if not response_data.get("success", False):
378
- error_code = response_data.get("error_code", "UNKNOWN")
379
- error_message = response_data.get("error_message", "Unknown error")
380
- error_msg = f"API returned error: {error_code} - {error_message}"
381
- logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
382
- raise HTTPException(
383
- status_code=500,
384
- detail=f"Botrun API error: {error_message}"
385
- )
386
-
387
- # 取得 session_id
388
- session_id = response_data.get("session_id")
389
- if not session_id:
390
- error_msg = "No session_id in API response"
391
- logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
392
- raise HTTPException(
393
- status_code=500,
394
- detail="Failed to get session ID from Botrun API"
395
- )
396
-
397
- # 取得 access_token
398
- access_token = response_data.get("access_token")
399
- if not access_token:
400
- error_msg = "No access_token in API response"
401
- logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
402
- raise HTTPException(
403
- status_code=500,
404
- detail="Failed to get access token from Botrun API"
405
- )
406
-
407
- # 組合最終的 URL
408
- # 確保 URL 不會有雙斜線
409
- front_url = BOTRUN_FRONT_URL.rstrip("/")
410
- botrun_url = f"{front_url}/b/{SUBSIDY_LINE_BOT_BOTRUN_ID}/s/{session_id}?external=true&hideBotrunHatch=true&hideUserInfo=true&botrun_token={access_token}"
411
-
412
- logging.info(f"[create_botrun_url_to_feedback] Successfully created botrun URL")
413
- logging.info(f"[create_botrun_url_to_feedback] Session ID: {response_data.get('session_id')}")
414
-
415
- return botrun_url
416
-
417
- except aiohttp.ClientError as e:
418
- error_msg = f"Network error calling Botrun API: {str(e)}"
419
- logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
420
- raise HTTPException(
421
- status_code=500,
422
- detail="Failed to connect to Botrun API"
423
- )
424
- except HTTPException:
425
- # 重新拋出 HTTPException
426
- raise
427
- except Exception as e:
428
- error_msg = f"Unexpected error: {str(e)}"
429
- logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
430
- logging.error(traceback.format_exc())
431
- raise HTTPException(
432
- status_code=500,
433
- detail="An unexpected error occurred"
434
- )
435
-
436
-
437
- @router.post("/subsidy/webhook")
438
- async def subsidy_webhook(request: Request):
439
- from linebot.v3.exceptions import InvalidSignatureError
440
- from linebot.v3.webhook import WebhookParser
441
- from linebot.v3.messaging import AsyncApiClient, Configuration
442
-
443
- signature = request.headers["X-Line-Signature"]
444
- if SUBSIDY_LINE_BOT_CHANNEL_SECRET is None:
445
- raise HTTPException(
446
- status_code=500, detail="SUBSIDY_LINE_BOT_CHANNEL_SECRET is not set"
447
- )
448
- if SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN is None:
449
- raise HTTPException(
450
- status_code=500, detail="SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN is not set"
451
- )
452
- parser = WebhookParser(SUBSIDY_LINE_BOT_CHANNEL_SECRET)
453
- configuration = Configuration(access_token=SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN)
454
-
455
- # get request body as text
456
- body = await request.body()
457
- body_str = body.decode("utf-8")
458
- body_json = json.loads(body_str)
459
- logging.info(
460
- "[Line Bot Webhook: subsidy_webhook] Received webhook: %s",
461
- json.dumps(body_json, indent=2, ensure_ascii=False),
462
- )
463
-
464
- try:
465
- events = parser.parse(body_str, signature)
466
- except InvalidSignatureError:
467
- raise HTTPException(status_code=400, detail="Invalid signature")
468
-
469
- start = time.time()
470
- env_name = os.getenv("ENV_NAME", "botrun-flow-lang-dev")
471
- subsidy_line_bot_graph = SearchAgentGraph(
472
- memory=AsyncFirestoreCheckpointer(env_name=env_name)
473
- ).graph
474
- logging.info(
475
- f"[Line Bot Webhook: subsidy_webhook] init graph took {time.time() - start:.3f}s"
476
- )
477
-
478
- responses = []
479
- async with AsyncApiClient(configuration) as async_api_client:
480
- line_bot_api = AsyncMessagingApi(async_api_client)
481
- # logging.info(f"[line_bot_api] subsidy_webhook / len(events): {len(events)}")
482
- for event in events:
483
- # 處理使用者傳送詢問訊息的事件
484
- if isinstance(event, MessageEvent) and isinstance(
485
- event.message, TextMessageContent
486
- ):
487
- # response = await handle_message(
488
- # event,
489
- # line_bot_api,
490
- # RATE_LIMIT_WINDOW,
491
- # RATE_LIMIT_COUNT,
492
- # subsidy_line_bot_graph,
493
- # request,
494
- # )
495
- logging.info("[handle_message] Start handling message event")
496
- from linebot.v3.messaging import (
497
- ReplyMessageRequest,
498
- TextMessage,
499
- )
500
-
501
- try:
502
- botrun_url = await create_botrun_url_to_feedback(event)
503
- await line_bot_api.reply_message(
504
- ReplyMessageRequest(
505
- reply_token=event.reply_token,
506
- messages=[TextMessage(text=f"訊息收到了,請用以下連結進行問答:\n{botrun_url}")],
507
- )
508
- )
509
- responses.append({"status": "success", "url": botrun_url})
510
-
511
- except HTTPException as e:
512
- # 處理 create_botrun_url_to_feedback 拋出的 HTTPException
513
- error_message = "很抱歉,系統暫時無法處理您的訊息,請稍後再試。"
514
- logging.error(f"[subsidy_webhook] Failed to create botrun URL: {e.detail}")
515
-
516
- # 回覆使用者錯誤訊息
517
- await line_bot_api.reply_message(
518
- ReplyMessageRequest(
519
- reply_token=event.reply_token,
520
- messages=[TextMessage(text=error_message)],
521
- )
522
- )
523
- responses.append({"status": "error", "error": str(e.detail)})
524
-
525
- except Exception as e:
526
- # 處理其他未預期的錯誤
527
- error_message = "很抱歉,系統發生錯誤,請稍後再試。"
528
- logging.error(f"[subsidy_webhook] Unexpected error: {str(e)}")
529
- logging.error(traceback.format_exc())
530
-
531
- # 回覆使用者錯誤訊息
532
- await line_bot_api.reply_message(
533
- ReplyMessageRequest(
534
- reply_token=event.reply_token,
535
- messages=[TextMessage(text=error_message)],
536
- )
537
- )
538
- responses.append({"status": "error", "error": str(e)})
539
-
540
- # 處理使用者藉由按讚反讚按鈕反饋的postback事件
541
- elif isinstance(event, PostbackEvent):
542
- # await handle_feedback(event, line_bot_api, subsidy_line_bot_graph)
543
- responses.append("feedback_handled")
544
-
545
- return {"responses": responses}
546
-
547
-
548
- async def get_user_display_name(user_id: str, line_bot_api: AsyncMessagingApi) -> str:
549
- """
550
- 取得使用者的Line顯示名稱
551
-
552
- Args:
553
- user_id (str): 使用者ID
554
- line_bot_api (AsyncMessagingApi): LINE Bot API 客戶端
555
-
556
- Returns:
557
- user_display_name (str): 使用者的Line顯示名稱
558
- """
559
- try:
560
- user_profile = await line_bot_api.get_profile(user_id)
561
- return user_profile.display_name
562
- except Exception as e:
563
- logging.error(
564
- f"[Line Bot Webhook: get_user_display_name] 無法取得使用者 {user_id} 的顯示名稱: {str(e)}"
565
- )
566
-
567
-
568
- async def handle_message(
569
- event: MessageEvent,
570
- line_bot_api: AsyncMessagingApi,
571
- rate_limit_window: int,
572
- rate_limit_count: int,
573
- line_bot_graph: SearchAgentGraph,
574
- request: Request,
575
- ):
576
- """處理 LINE Bot 的訊息事件
577
-
578
- 處理使用者傳送的文字訊息,包括頻率限制檢查、訊息分段與回覆等操作
579
-
580
- Args:
581
- event (MessageEvent): LINE Bot 的訊息事件
582
- line_bot_api (AsyncMessagingApi): LINE Bot API 客戶端
583
- rate_limit_window (int): 訊息頻率限制時間窗口(秒)
584
- rate_limit_count (int): 訊息頻率限制數量
585
- line_bot_graph (SearchAgentGraph): LINE Bot 的 agent graph
586
- request (Request): FastAPI request 物件,用於記錄到 BigQuery
587
- """
588
- start = time.time()
589
- logging.info(
590
- "[Line Bot Webhook: handle_message] Enter handle_message for event type: %s",
591
- event.type,
592
- )
593
- from linebot.v3.messaging import (
594
- ReplyMessageRequest,
595
- TextMessage,
596
- FlexMessage,
597
- FlexBubble,
598
- FlexBox,
599
- FlexText,
600
- FlexButton,
601
- MessageAction,
602
- QuickReply,
603
- QuickReplyItem,
604
- PostbackAction,
605
- )
606
-
607
- # 已經移至常量部分定義
608
- user_id = event.source.user_id
609
- user_message = event.message.text
610
- display_name = await get_user_display_name(user_id, line_bot_api)
611
- logging.info(
612
- f"[Line Bot Webhook: handle_message] 收到來自 {display_name} ({user_id}) 的訊息"
613
- )
614
-
615
- # 背景記錄使用者訊息到 BigQuery (不等待完成,避免影響回應速度)
616
- asyncio.create_task(
617
- log_to_bigquery(
618
- user_id,
619
- display_name,
620
- "llm_input",
621
- user_message,
622
- DEFAULT_MODEL_NAME,
623
- request,
624
- )
625
- )
626
-
627
- if user_message.lower().strip() == "reset":
628
- env_name = os.getenv("ENV_NAME", "botrun-flow-lang-dev")
629
- await AsyncFirestoreCheckpointer(env_name=env_name).adelete_thread(user_id)
630
- await line_bot_api.reply_message(
631
- ReplyMessageRequest(
632
- reply_token=event.reply_token,
633
- messages=[TextMessage(text="已清除記憶,請重新開始對話")],
634
- )
635
- )
636
- return {"message": "已清除記憶,請重新開始對話"}
637
-
638
- if user_id in _processing_users:
639
- logging.info(
640
- f"[Line Bot Webhook: handle_message] 使用者 {display_name} ({user_id}) 已有處理中的訊息,回覆等待提示"
641
- )
642
- reply_text = "您的上一條訊息正在處理中,請稍候再發送新訊息"
643
- await line_bot_api.reply_message(
644
- ReplyMessageRequest(
645
- reply_token=event.reply_token,
646
- messages=[TextMessage(text=reply_text)],
647
- )
648
- )
649
- return {"message": reply_text}
650
-
651
- # 檢查使用者是否超過訊息頻率限制
652
- is_rate_limited, wait_seconds = check_rate_limit(
653
- user_id, rate_limit_window, rate_limit_count
654
- )
655
- if is_rate_limited:
656
- logging.info(
657
- f"[Line Bot Webhook: handle_message] 使用者 {display_name} ({user_id}) 超過訊息頻率限制,需等待 {wait_seconds} 秒"
658
- )
659
-
660
- # 回覆頻率限制提示
661
- window_minutes = rate_limit_window // 60
662
- wait_minutes = max(1, wait_seconds // 60)
663
- reply_text = f"您發送訊息的頻率過高,{window_minutes}分鐘內最多可發送{rate_limit_count}則訊息。請等待約 {wait_minutes} 分鐘後再試。"
664
- await line_bot_api.reply_message(
665
- ReplyMessageRequest(
666
- reply_token=event.reply_token,
667
- messages=[TextMessage(text=reply_text)],
668
- )
669
- )
670
- return {"message": reply_text}
671
-
672
- # 標記使用者為處理中
673
- _processing_users.add(user_id)
674
-
675
- try:
676
- reply_text, related_questions = await get_reply_text(
677
- line_bot_graph, user_message, user_id, display_name, request
678
- )
679
- logging.info(
680
- f"[Line Bot Webhook: handle_message] Total response length: {len(reply_text)}"
681
- )
682
-
683
- # 將長訊息分段,每段不超過 LINE_MAX_MESSAGE_LENGTH
684
- message_chunks = []
685
- remaining_text = reply_text
686
-
687
- while remaining_text:
688
- # 如果剩餘文字長度在限制內,直接加入並結束
689
- if len(remaining_text) <= LINE_MAX_MESSAGE_LENGTH:
690
- message_chunks.append(remaining_text)
691
- logging.info(
692
- f"[Line Bot Webhook: handle_message] Last chunk length: {len(remaining_text)}"
693
- )
694
- break
695
-
696
- # 確保分段大小在限制內
697
- safe_length = min(
698
- LINE_MAX_MESSAGE_LENGTH - 100, len(remaining_text)
699
- ) # 預留一些空間
700
-
701
- # 在安全長度內尋找最後一個完整句子
702
- chunk_end = safe_length
703
- for i in range(safe_length - 1, max(0, safe_length - 200), -1):
704
- if remaining_text[i] in "。!?!?":
705
- chunk_end = i + 1
706
- break
707
-
708
- # 如果找不到適合的句子結尾,就用空格或換行符號來分割
709
- if chunk_end == safe_length:
710
- for i in range(safe_length - 1, max(0, safe_length - 200), -1):
711
- if remaining_text[i] in " \n":
712
- chunk_end = i + 1
713
- break
714
- # 如果還是找不到合適的分割點,就直接在安全長度處截斷
715
- if chunk_end == safe_length:
716
- chunk_end = safe_length
717
-
718
- # 加入這一段文字
719
- current_chunk = remaining_text[:chunk_end]
720
- logging.info(
721
- f"[Line Bot Webhook: handle_message] Current chunk length: {len(current_chunk)}"
722
- )
723
- message_chunks.append(current_chunk)
724
-
725
- # 更新剩餘文字
726
- remaining_text = remaining_text[chunk_end:]
727
-
728
- logging.info(
729
- f"[Line Bot Webhook: handle_message] Number of chunks: {len(message_chunks)}"
730
- )
731
- for i, chunk in enumerate(message_chunks):
732
- logging.info(
733
- f"[Line Bot Webhook: handle_message] Chunk {i} length: {len(chunk)}"
734
- )
735
-
736
- # 創建訊息列表
737
- messages = []
738
-
739
- # 添加所有文字訊息區塊
740
- for i, chunk in enumerate(message_chunks):
741
- messages.append(TextMessage(text=chunk))
742
-
743
- # 添加相關問題按鈕
744
- question_bubble = None
745
- if related_questions:
746
- title = FlexText(
747
- text="以下是您可能想要了解的相關問題:",
748
- weight="bold",
749
- size="md",
750
- wrap=True,
751
- )
752
- buttons = [
753
- FlexButton(
754
- action=MessageAction(label=q[:20], text=q),
755
- style="secondary",
756
- margin="sm",
757
- height="sm",
758
- scaling=True,
759
- adjust_mode="shrink-to-fit",
760
- )
761
- for q in related_questions
762
- ]
763
- question_bubble = FlexBubble(
764
- body=FlexBox(
765
- layout="vertical", spacing="sm", contents=[title, *buttons]
766
- )
767
- )
768
-
769
- # 以 Quick Reply 作為按讚反讚按鈕
770
- quick_reply = QuickReply(
771
- items=[
772
- QuickReplyItem(
773
- action=PostbackAction(
774
- label="津好康,真是棒👍🏻",
775
- data="實用",
776
- display_text="津好康,真是棒👍🏻",
777
- )
778
- ),
779
- QuickReplyItem(
780
- action=PostbackAction(
781
- label="津可惜,不太實用😖",
782
- data="不實用",
783
- display_text="津可惜,不太實用😖",
784
- )
785
- ),
786
- ]
787
- )
788
-
789
- if question_bubble:
790
- messages.append(FlexMessage(alt_text="相關問題", contents=question_bubble))
791
-
792
- messages[-1].quick_reply = quick_reply
793
-
794
- await line_bot_api.reply_message(
795
- ReplyMessageRequest(reply_token=event.reply_token, messages=messages)
796
- )
797
- except Exception as e:
798
- logging.error(
799
- f"[Line Bot Webhook: handle_message] 處理使用者 {display_name} ({user_id}) 訊息時發生錯誤: {e}"
800
- )
801
- traceback.print_exc()
802
- reply_text = "很抱歉,處理您的訊息時遇到問題,請稍後再試"
803
- try:
804
- await line_bot_api.reply_message(
805
- ReplyMessageRequest(
806
- reply_token=event.reply_token,
807
- messages=[TextMessage(text=reply_text)],
808
- )
809
- )
810
- except Exception as reply_error:
811
- logging.error(
812
- f"[Line Bot Webhook: handle_message] 無法發送錯誤回覆: {reply_error}"
813
- )
814
- traceback.print_exc()
815
- finally:
816
- logging.info(
817
- f"[Line Bot Webhook: handle_message] total elapsed {time.time() - start:.3f}s"
818
- )
819
- _processing_users.discard(user_id)
820
- logging.info(
821
- f"[Line Bot Webhook: handle_message] 使用者 {display_name} ({user_id}) 的訊息處理完成"
822
- )
823
-
824
- return {"message": reply_text}
825
-
826
-
827
- def check_rate_limit(user_id: str, window: int, count: int) -> Tuple[bool, int]:
828
- """檢查使用者是否超過訊息頻率限制
829
-
830
- 檢查使用者在指定時間窗口內發送的訊息數量是否超過限制。
831
- 同時清理過期的時間戳記,以避免記憶體無限增長。
832
-
833
- Args:
834
- user_id (str): 使用者的 LINE ID
835
- window (int): 時間窗口(秒)
836
- count (int): 訊息數量限制
837
-
838
- Returns:
839
- Tuple[bool, int]: (是否超過限制, 需要等待的秒數)
840
- 如果未超過限制,第二個值為 0
841
- """
842
- current_time = time.time()
843
- user_timestamps = _user_message_timestamps[user_id]
844
-
845
- # 清理過期的時間戳記(超過時間窗口的)
846
- while user_timestamps and current_time - user_timestamps[0] > window:
847
- user_timestamps.popleft()
848
-
849
- # 如果清理後沒有時間戳記,則從字典中移除該使用者的記錄
850
- if not user_timestamps:
851
- del _user_message_timestamps[user_id]
852
- # 如果使用者沒有有效的時間戳記,則直接添加新的時間戳記
853
- _user_message_timestamps[user_id].append(current_time)
854
- return False, 0
855
-
856
- # 檢查是否超過限制
857
- if len(user_timestamps) >= count:
858
- # 計算需要等待的時間
859
- oldest_timestamp = user_timestamps[0]
860
- wait_time = int(window - (current_time - oldest_timestamp))
861
- return True, max(0, wait_time)
862
-
863
- # 未超過限制,添加當前時間戳記
864
- user_timestamps.append(current_time)
865
-
866
- return False, 0
867
-
868
-
869
- async def get_reply_text(
870
- line_bot_graph,
871
- line_user_message: str,
872
- user_id: str,
873
- display_name: str,
874
- request: Request,
875
- ) -> tuple[str, list]:
876
- """
877
- 使用 agent_runner 處理使用者訊息並回傳回覆內容
878
-
879
- Args:
880
- line_bot_graph (SearchAgentGraph): LINE Bot 的 agent graph
881
- line_user_message (str): 使用者傳送的 LINE 訊息內容
882
- user_id (str): 使用者的 LINE ID
883
- display_name (str): 使用者的 Line 顯示名稱
884
- request (Request): FastAPI request 物件,用於記錄到 BigQuery
885
-
886
- Returns:
887
- tuple[str, list]: 包含回覆訊息和相關問題的元組
888
- """
889
- start_time = time.time()
890
- full_response = ""
891
- chat_model_events = [] # 收集 ChatModelEndEvent
892
-
893
- async for event_chunk in agent_runner(
894
- user_id,
895
- {"messages": [line_user_message]},
896
- line_bot_graph,
897
- extra_config=get_subsidy_bot_search_config(),
898
- ):
899
- if isinstance(event_chunk, OnNodeStreamEvent):
900
- # 處理串流文字事件
901
- full_response += event_chunk.chunk
902
- if isinstance(event_chunk, ChatModelEndEvent):
903
- # 收集 ChatModelEndEvent 待後續處理
904
- chat_model_events.append(event_chunk)
905
-
906
- # 迴圈結束後,處理所有收集到的 ChatModelEndEvent 並記錄到 BigQuery
907
- for event_chunk in chat_model_events:
908
- try:
909
- # 使用輔助函數處理事件資料
910
- ai_message_outputs = _extract_ai_message_outputs(
911
- event_chunk.raw_output, event_chunk.langgraph_node
912
- )
913
- inputs = _extract_input_messages(event_chunk.raw_input)
914
-
915
- # 處理節點名稱映射
916
- processed_node_name = event_chunk.langgraph_node
917
- if processed_node_name == "extract":
918
- processed_node_name = "requirement_node_extract"
919
-
920
- # 準備資源 ID
921
- resource_id = ""
922
- if event_chunk.usage_metadata:
923
- resource_id = json.dumps(event_chunk.usage_metadata, ensure_ascii=False)
924
-
925
- # 準備要記錄的訊息內容
926
- log_message_parts = []
927
- if ai_message_outputs:
928
- log_message_parts.append(ai_message_outputs)
929
- if inputs:
930
- inputs_text = "".join(inputs)
931
- log_message_parts.append(inputs_text)
932
-
933
- log_message = "".join(log_message_parts)
934
-
935
- logging.info(
936
- f"[Line Bot Webhook: get_reply_text - collect log info for BigQuery] langgraph_node: {processed_node_name}"
937
- )
938
- logging.info(
939
- f"[Line Bot Webhook: get_reply_text - collect log info for BigQuery] model_name: {event_chunk.model_name}"
940
- )
941
- logging.info(
942
- f"[Line Bot Webhook: get_reply_text - collect log info for BigQuery] resource_id: {resource_id}"
943
- )
944
- logging.info(
945
- f"[Line Bot Webhook: get_reply_text - collect log info for BigQuery] ai_message_outputs: {ai_message_outputs}"
946
- )
947
- for i, input in enumerate(inputs, start=1):
948
- logging.info(
949
- f"[Line Bot Webhook: get_reply_text - collect log info for BigQuery] inputs_{i}: {input}"
950
- )
951
-
952
- logging.info(
953
- f"[Line Bot Webhook: get_reply_text - collect log info for BigQuery] log_message: {log_message}"
954
- )
955
-
956
- # 異步記錄到 BigQuery
957
- asyncio.create_task(
958
- log_to_bigquery(
959
- user_id=user_id,
960
- display_name=display_name,
961
- action_type=f"langgraph_agent_api-[{processed_node_name}]",
962
- message=log_message,
963
- model=event_chunk.model_name,
964
- request=request,
965
- resource_id=resource_id,
966
- )
967
- )
968
-
969
- logging.info(
970
- f"[Line Bot Webhook: get_reply_text] Logged ChatModelEndEvent to BigQuery for user {user_id}"
971
- )
972
-
973
- except Exception as e:
974
- logging.error(
975
- f"[Line Bot Webhook: get_reply_text] Failed to log ChatModelEndEvent to BigQuery: {e}"
976
- )
977
-
978
- # 記錄 LLM 輸出到 BigQuery
979
- asyncio.create_task(
980
- log_to_bigquery(
981
- user_id,
982
- display_name,
983
- "llm_output",
984
- full_response,
985
- DEFAULT_MODEL_NAME,
986
- request,
987
- )
988
- )
989
-
990
- if "</think>" in full_response:
991
- full_response = full_response.split("</think>", 1)[1].lstrip()
992
-
993
- full_response += "\n" + os.getenv("SUBSIDY_LINEBOT_FOOTNOTE", "")
994
-
995
- # 取得相關問題但不附加到回覆內容
996
- related_questions = []
997
- try:
998
- # 嘗試使用非同步方式取得 state(若 checkpointer 為非同步型別)
999
- try:
1000
- state_obj = await line_bot_graph.aget_state(
1001
- {"configurable": {"thread_id": user_id}}
1002
- )
1003
- except AttributeError:
1004
- # 回退到同步方法
1005
- state_obj = line_bot_graph.get_state(
1006
- {"configurable": {"thread_id": user_id}}
1007
- )
1008
-
1009
- # 根據返回型別(dict 或具備屬性)解析
1010
- if isinstance(state_obj, dict):
1011
- related_questions = state_obj.get("related_questions", [])
1012
- elif hasattr(state_obj, "related_questions"):
1013
- related_questions = getattr(state_obj, "related_questions", [])
1014
- elif hasattr(state_obj, "values") and isinstance(state_obj.values, dict):
1015
- related_questions = state_obj.values.get("related_questions", [])
1016
- except Exception as e:
1017
- logging.error(
1018
- f"[Line Bot Webhook: get_reply_text] Failed to append related questions: {e}"
1019
- )
1020
-
1021
- logging.info(
1022
- f"[Line Bot Webhook: get_reply_text] total took {time.time() - start_time:.3f}s"
1023
- )
1024
-
1025
- return full_response, related_questions
1026
-
1027
-
1028
- async def handle_feedback(
1029
- event: PostbackEvent,
1030
- line_bot_api: AsyncMessagingApi,
1031
- subsidy_line_bot_graph,
1032
- ):
1033
- """處理使用者透過 Quick Reply 按鈕提供的回饋
1034
-
1035
- Args:
1036
- event (PostbackEvent): LINE Bot 的 postback 事件
1037
- line_bot_api (AsyncMessagingApi): LINE Bot API 客戶端
1038
- subsidy_line_bot_graph: LINE Bot 的 graph 實例,用來取得對話歷史
1039
- """
1040
- try:
1041
- user_id = event.source.user_id
1042
- feedback_data = event.postback.data
1043
- display_name = await get_user_display_name(user_id, line_bot_api)
1044
-
1045
- taiwan_tz = pytz.timezone("Asia/Taipei")
1046
- current_time = datetime.now(taiwan_tz)
1047
- formatted_time = current_time.strftime("%Y-%m-%d %H:%M:%S")
1048
-
1049
- # 從 graph state 中取得對話歷史
1050
- config = {"configurable": {"thread_id": user_id}}
1051
- try:
1052
- from langchain_core.messages import HumanMessage, AIMessage
1053
-
1054
- state = await subsidy_line_bot_graph.aget_state(config)
1055
- messages = state.values.get("messages", [])
1056
-
1057
- # 找到最新的使用者提問和 AI 回答
1058
- latest_user_question = ""
1059
- latest_ai_response = ""
1060
-
1061
- # 從後往前查找最新的對話
1062
- for i in range(len(messages) - 1, -1, -1):
1063
- message = messages[i]
1064
- # 檢查是否為 AI 訊息且不是工具呼叫
1065
- if isinstance(message, AIMessage) and not getattr(
1066
- message, "tool_calls", None
1067
- ):
1068
- if not latest_ai_response:
1069
- latest_ai_response = str(message.content)
1070
- elif isinstance(message, HumanMessage):
1071
- if not latest_user_question:
1072
- latest_user_question = str(message.content)
1073
- # 如果已經找到最新的使用者問題,就停止搜尋
1074
- if latest_ai_response:
1075
- break
1076
-
1077
- except Exception as e:
1078
- logging.error(f"[Line Bot Webhook: handle_feedback] 無法取得對話歷史: {e}")
1079
- latest_user_question = "無法取得"
1080
- latest_ai_response = "無法取得"
1081
-
1082
- if "</think>" in latest_ai_response:
1083
- latest_ai_response = latest_ai_response.split("</think>", 1)[1].lstrip()
1084
-
1085
- # 記錄詳細的回饋資訊
1086
- logging.info(
1087
- f"[Line Bot Webhook: handle_feedback] 回饋詳細資訊:\n"
1088
- f" 建立時間: {formatted_time}\n"
1089
- f" 使用者ID: {user_id}\n"
1090
- f" 使用者Line顯示名稱: {display_name}\n"
1091
- f" 使用者輸入: {latest_user_question}\n"
1092
- f" LineBot回應: {latest_ai_response}\n"
1093
- f" 反饋: {feedback_data}"
1094
- )
1095
-
1096
- # 先回覆使用者已收到回饋的訊息
1097
- from linebot.v3.messaging import TextMessage, ReplyMessageRequest
1098
-
1099
- reply_text = "已收到您的回饋。"
1100
- await line_bot_api.reply_message(
1101
- ReplyMessageRequest(
1102
- reply_token=event.reply_token,
1103
- messages=[TextMessage(text=reply_text)],
1104
- )
1105
- )
1106
-
1107
- # 使用 asyncio.create_task 在背景執行更新使用者回饋到 Google Sheet
1108
- feedback_dict = {
1109
- "建立時間": formatted_time,
1110
- "使用者ID": user_id,
1111
- "使用者Line顯示名稱": display_name,
1112
- "使用者輸入": latest_user_question,
1113
- "LineBot回應": latest_ai_response,
1114
- "反饋": feedback_data,
1115
- }
1116
- asyncio.create_task(update_feedback_to_gsheet(feedback_dict))
1117
- except Exception as e:
1118
- logging.error(
1119
- f"[Line Bot Webhook: handle_feedback] 處理使用者 {display_name} ({user_id}) 回饋時發生錯誤: {e}"
1120
- )
1121
- traceback.print_exc()
1122
-
1123
-
1124
- async def update_feedback_to_gsheet(feedback_data: dict):
1125
- """更新回饋資料到 Google Sheets"""
1126
- try:
1127
- service_account_file = os.getenv(
1128
- "GOOGLE_APPLICATION_CREDENTIALS_FOR_SUBSIDY_LINEBOT"
1129
- )
1130
- spreadsheet_id = os.getenv("SUBSIDY_LINEBOT_GSPREAD_ID")
1131
-
1132
- if not service_account_file:
1133
- logging.error(
1134
- "[Line Bot Webhook: update_feedback_to_gsheet] 環境變數 GOOGLE_APPLICATION_CREDENTIALS_FOR_SUBSIDY_LINEBOT 未設定"
1135
- )
1136
- return
1137
-
1138
- if not spreadsheet_id:
1139
- logging.error(
1140
- "[Line Bot Webhook: update_feedback_to_gsheet] 環境變數 SUBSIDY_LINEBOT_GSPREAD_ID 未設定"
1141
- )
1142
- return
1143
-
1144
- if not os.path.exists(service_account_file):
1145
- logging.error(
1146
- f"[Line Bot Webhook: update_feedback_to_gsheet] 服務帳戶檔案不存在: {service_account_file}"
1147
- )
1148
- return
1149
-
1150
- worksheet_name = "LineBot意見回饋"
1151
- headers = [
1152
- "建立時間",
1153
- "使用者ID",
1154
- "使用者Line顯示名稱",
1155
- "使用者輸入",
1156
- "LineBot回應",
1157
- "反饋",
1158
- ]
1159
-
1160
- success = create_sheet_if_not_exists(
1161
- service_account_file=service_account_file,
1162
- spreadsheet_id=spreadsheet_id,
1163
- sheet_name=worksheet_name,
1164
- headers=headers,
1165
- )
1166
-
1167
- if not success:
1168
- logging.error(
1169
- "[Line Bot Webhook: update_feedback_to_gsheet] 無法建立或存取工作表"
1170
- )
1171
- return
1172
-
1173
- result = append_data_to_gsheet(
1174
- service_account_file=service_account_file,
1175
- spreadsheet_id=spreadsheet_id,
1176
- sheet_name=worksheet_name,
1177
- data_dict=feedback_data,
1178
- )
1179
-
1180
- logging.info(
1181
- f"[Line Bot Webhook: update_feedback_to_gsheet] 已成功將使用者回饋寫入 Google Sheet {worksheet_name}"
1182
- )
1183
-
1184
- except Exception as e:
1185
- logging.error(
1186
- f"[Line Bot Webhook: update_feedback_to_gsheet] 將使用者回饋寫入 Google Sheet 時發生錯誤: {e}"
1187
- )
1188
- import traceback
1189
-
1190
- traceback.print_exc()
1191
-
1192
-
1193
- class MulticastMessage(BaseModel):
1194
- message: str
1195
-
1196
-
1197
- @router.post("/subsidy/multicast_msg", dependencies=[Depends(verify_token)])
1198
- async def subsidy_multicast_msg(body: MulticastMessage):
1199
- """
1200
- 透過 LINE Multicast API 將文字訊息一次推播給 Google Sheet「LineBot使用者ID表」中的所有使用者。
1201
-
1202
- 請以 JSON 格式提供要推播的訊息:{ "message": "要推播的訊息" }
1203
- """
1204
- try:
1205
- text = body.message
1206
- if not text:
1207
- raise HTTPException(
1208
- status_code=400, detail="Request JSON must contain 'message'"
1209
- )
1210
-
1211
- # 檢查 Access Token
1212
- if SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN is None:
1213
- raise HTTPException(
1214
- status_code=500,
1215
- detail="SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN is not set",
1216
- )
1217
-
1218
- # 取得 Google Sheet 設定
1219
- service_account_file = os.getenv(
1220
- "GOOGLE_APPLICATION_CREDENTIALS_FOR_SUBSIDY_LINEBOT"
1221
- )
1222
- spreadsheet_id = os.getenv("SUBSIDY_LINEBOT_GSPREAD_ID")
1223
- if not service_account_file or not spreadsheet_id:
1224
- raise HTTPException(status_code=500, detail="Google Sheet env vars not set")
1225
-
1226
- sheet_name = "LineBot使用者ID表"
1227
- try:
1228
- sheet_content = get_sheet_content(
1229
- service_account_file, spreadsheet_id, sheet_name
1230
- )
1231
- except Exception as e:
1232
- logging.error(f"[Line Bot Multicast] Failed to read Google Sheet: {e}")
1233
- raise HTTPException(
1234
- status_code=500,
1235
- detail="Failed to read user list from Google Sheet",
1236
- )
1237
-
1238
- if "user_id" not in sheet_content:
1239
- raise HTTPException(
1240
- status_code=400, detail="Sheet missing 'user_id' column"
1241
- )
1242
-
1243
- user_ids = sheet_content.get("user_id", [])
1244
-
1245
- logging.info(
1246
- f"[Line Bot Multicast] Retrieved {len(user_ids)} user_ids: {user_ids}"
1247
- )
1248
-
1249
- if not user_ids:
1250
- raise HTTPException(status_code=400, detail="No user IDs to send")
1251
-
1252
- from linebot.v3.messaging import (
1253
- AsyncApiClient,
1254
- Configuration,
1255
- TextMessage,
1256
- MulticastRequest,
1257
- )
1258
-
1259
- configuration = Configuration(
1260
- access_token=SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN
1261
- )
1262
- async with AsyncApiClient(configuration) as async_api_client:
1263
- line_bot_api = AsyncMessagingApi(async_api_client)
1264
- CHUNK_SIZE = 500 # LINE Multicast 單次最多 500 個使用者
1265
- for i in range(0, len(user_ids), CHUNK_SIZE):
1266
- chunk_ids = user_ids[i : i + CHUNK_SIZE]
1267
- multicast_request = MulticastRequest(
1268
- to=chunk_ids, messages=[TextMessage(text=text)]
1269
- )
1270
- await line_bot_api.multicast(multicast_request)
1271
-
1272
- logging.info(
1273
- f"[Line Bot Multicast] Successfully sent multicast to {len(user_ids)} users"
1274
- )
1275
-
1276
- return {"status": "ok", "sent_to": len(user_ids)}
1277
-
1278
- except HTTPException:
1279
- raise
1280
- except Exception as e:
1281
- logging.error(f"[Line Bot Multicast] Unexpected error: {e}")
1282
- traceback.print_exc()
1283
- raise HTTPException(status_code=500, detail="Internal server error")
1284
-
1285
-
1286
- def _extract_ai_message_outputs(raw_output, langgraph_node: str) -> str:
1287
- """根據 langgraph_node 從 raw_output 中提取 AI 訊息輸出"""
1288
- ai_message_outputs = ""
1289
-
1290
- try:
1291
- if langgraph_node == "extract":
1292
- if hasattr(raw_output, "tool_calls"):
1293
- for tool_call in raw_output.tool_calls:
1294
- if tool_call.get("name") == "RequirementPromptInstructions":
1295
- args = tool_call.get("args", {})
1296
- ai_message_outputs = str(args)
1297
- break
1298
- elif langgraph_node in ["search_node", "normal_chat_node"]:
1299
- if hasattr(raw_output, "content"):
1300
- ai_message_outputs = str(raw_output.content)
1301
- elif langgraph_node == "related_node":
1302
- if (
1303
- hasattr(raw_output, "additional_kwargs")
1304
- and "tool_calls" in raw_output.additional_kwargs
1305
- ):
1306
- tool_calls = raw_output.additional_kwargs["tool_calls"]
1307
- for tool_call in tool_calls:
1308
- if (
1309
- tool_call.get("function", {}).get("name")
1310
- == "RelatedQuestionsInstructions"
1311
- ):
1312
- arguments = json.loads(tool_call["function"]["arguments"])
1313
- related_questions = arguments.get("related_questions", [])
1314
- ai_message_outputs = "; ".join(related_questions)
1315
- break
1316
- except Exception as e:
1317
- logging.error(
1318
- f"[Line Bot API] Failed to extract AI message outputs for {langgraph_node}: {e}"
1319
- )
1320
-
1321
- return ai_message_outputs
1322
-
1323
-
1324
- def _extract_input_messages(raw_input) -> list[str]:
1325
- """從 raw_input 中提取所有輸入訊息"""
1326
- inputs = []
1327
-
1328
- try:
1329
- # 檢查 raw_input 是否為字典且包含 messages
1330
- if isinstance(raw_input, dict) and "messages" in raw_input:
1331
- messages = raw_input["messages"]
1332
- ai_messages = []
1333
- human_messages = []
1334
- system_messages = []
1335
-
1336
- for msg in messages:
1337
- for nested_msg in msg:
1338
- if hasattr(nested_msg, "__class__"):
1339
- msg_type = nested_msg.__class__.__name__
1340
- msg_content = str(getattr(nested_msg, "content", ""))
1341
-
1342
- if msg_type == "AIMessage":
1343
- ai_messages.append(msg_content)
1344
- elif msg_type == "HumanMessage":
1345
- human_messages.append(msg_content)
1346
- elif msg_type == "SystemMessage":
1347
- system_messages.append(msg_content)
1348
-
1349
- inputs = ai_messages + human_messages + system_messages
1350
- else:
1351
- # 如果 raw_input 不是預期的格式,嘗試轉換為字串
1352
- inputs = [str(raw_input)] if raw_input is not None else []
1353
- except Exception as e:
1354
- logging.error(f"[Line Bot API] Failed to extract input messages: {e}")
1355
- inputs = [str(raw_input)] if raw_input is not None else []
1356
-
1357
- return inputs
1
+ import os
2
+ import json
3
+ import time
4
+ import sys
5
+ import logging
6
+ import traceback
7
+ import asyncio
8
+ import aiohttp
9
+ from collections import defaultdict, deque
10
+ from typing import Tuple
11
+ from pathlib import Path
12
+ from datetime import datetime
13
+ import pytz
14
+
15
+ from fastapi import APIRouter, HTTPException, Request, Depends
16
+ from linebot.v3.webhooks import MessageEvent, TextMessageContent, PostbackEvent
17
+ from linebot.v3.messaging import AsyncMessagingApi
18
+ from pydantic import BaseModel
19
+ from botrun_log import Logger, TextLogEntry
20
+
21
+ from botrun_flow_lang.langgraph_agents.agents.agent_runner import (
22
+ agent_runner,
23
+ ChatModelEndEvent,
24
+ OnNodeStreamEvent,
25
+ )
26
+ from botrun_flow_lang.langgraph_agents.agents.search_agent_graph import (
27
+ DEFAULT_RELATED_PROMPT,
28
+ NORMAL_CHAT_PROMPT_TEXT,
29
+ REQUIREMENT_PROMPT_TEMPLATE,
30
+ SearchAgentGraph,
31
+ DEFAULT_SEARCH_CONFIG,
32
+ DEFAULT_MODEL_NAME,
33
+ )
34
+ from botrun_flow_lang.langgraph_agents.agents.checkpointer.firestore_checkpointer import (
35
+ AsyncFirestoreCheckpointer,
36
+ )
37
+ from botrun_flow_lang.utils.google_drive_utils import (
38
+ authenticate_google_services,
39
+ get_google_doc_mime_type,
40
+ get_google_doc_content_with_service,
41
+ create_sheet_if_not_exists,
42
+ append_data_to_gsheet,
43
+ get_sheet_content,
44
+ )
45
+ from botrun_flow_lang.api.auth_utils import verify_token
46
+
47
+
48
+ # 同時輸出到螢幕與本地 log 檔
49
+ LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
50
+
51
+ # 建立 handlers 清單供 basicConfig 使用
52
+ _console_handler = logging.StreamHandler(sys.stdout)
53
+ _console_handler.setFormatter(logging.Formatter(LOG_FORMAT))
54
+
55
+ handlers = [_console_handler]
56
+
57
+ # 透過環境變數 `IS_WRITE_LOG_TO_FILE` 決定是否寫入本地檔案
58
+ IS_WRITE_LOG_TO_FILE = os.getenv("IS_WRITE_LOG_TO_FILE", "false")
59
+ if IS_WRITE_LOG_TO_FILE == "true":
60
+ default_log_path = Path.cwd() / "logs" / "app.log"
61
+ log_file_path = Path(os.getenv("LINE_BOT_LOG_FILE", default_log_path))
62
+ log_file_path.parent.mkdir(parents=True, exist_ok=True)
63
+
64
+ _file_handler = logging.FileHandler(log_file_path, encoding="utf-8")
65
+ _file_handler.setFormatter(logging.Formatter(LOG_FORMAT))
66
+ handlers.append(_file_handler)
67
+
68
+ # 使用 basicConfig 重新配置 root logger;force=True 可覆蓋先前設定 (Py≥3.8)
69
+ logging.basicConfig(
70
+ level=logging.INFO, format=LOG_FORMAT, handlers=handlers, force=True
71
+ )
72
+
73
+ # 取得 module logger(會自動享有 root handlers)
74
+ # 如需調整本模組層級,可另行設定,但通常保持 INFO 即可。
75
+ logger = logging.getLogger(__name__)
76
+
77
+ # 常量定義
78
+ SUBSIDY_LINE_BOT_CHANNEL_SECRET = os.getenv("SUBSIDY_LINE_BOT_CHANNEL_SECRET", None)
79
+ SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN = os.getenv(
80
+ "SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN", None
81
+ )
82
+ RATE_LIMIT_WINDOW = int(
83
+ os.environ.get("SUBSIDY_LINEBOT_RATE_LIMIT_WINDOW", 60)
84
+ ) # 預設時間窗口為 1 分鐘 (60 秒)
85
+ RATE_LIMIT_COUNT = int(
86
+ os.environ.get("SUBSIDY_LINEBOT_RATE_LIMIT_COUNT", 2)
87
+ ) # 預設在時間窗口內允許的訊息數量 2
88
+ LINE_MAX_MESSAGE_LENGTH = 5000
89
+
90
+ # Botrun API 相關環境變數
91
+ BOTRUN_BACK_API_BASE = os.getenv("BOTRUN_BACK_API_BASE", None)
92
+ BOTRUN_BACK_LINE_AUTH_API_TOKEN = os.getenv("BOTRUN_BACK_LINE_AUTH_API_TOKEN", None)
93
+ SUBSIDY_LINE_BOT_BOTRUN_ID = os.getenv("SUBSIDY_LINE_BOT_BOTRUN_ID", "波津貼.botrun")
94
+ SUBSIDY_LINE_BOT_JWT_TOKEN_HOURS = int(
95
+ os.getenv("SUBSIDY_LINE_BOT_JWT_TOKEN_HOURS", "2")
96
+ )
97
+ SUBSIDY_LINE_BOT_USER_ROLE = os.getenv("SUBSIDY_LINE_BOT_USER_ROLE", "member")
98
+ BOTRUN_FRONT_URL = os.getenv("BOTRUN_FRONT_URL", None)
99
+
100
+ # 全局變數
101
+ # 用於追蹤正在處理訊息的使用者,避免同一使用者同時發送多條訊息造成處理衝突
102
+ _processing_users = set()
103
+ # 用於訊息頻率限制:追蹤每個使用者在時間窗口內發送的訊息時間戳記
104
+ # 使用 defaultdict(deque) 結構確保:1) 只記錄有發送訊息的使用者 2) 高效管理時間窗口內的訊息
105
+ _user_message_timestamps = defaultdict(deque)
106
+
107
+ # 初始化 subsidy_line_bot BigQuery Logger
108
+ try:
109
+ subsidy_line_bot_bq_logger = Logger(
110
+ db_type="bigquery",
111
+ department=os.getenv("BOTRUN_LOG_DEPARTMENT", "subsidy_line_bot"),
112
+ credentials_path=os.getenv(
113
+ "BOTRUN_LOG_CREDENTIALS_PATH",
114
+ "/app/botrun_flow_lang/keys/scoop-386004-e9c7b6084fb4.json",
115
+ ),
116
+ project_id=os.getenv("BOTRUN_LOG_PROJECT_ID", "scoop-386004"),
117
+ dataset_name=os.getenv("BOTRUN_LOG_DATASET_NAME", "subsidy_line_bot"),
118
+ )
119
+ except Exception as e:
120
+ pass
121
+
122
+
123
+ # 初始化 FastAPI 路由器,設定 API 路徑前綴
124
+ router = APIRouter(prefix="/line_bot")
125
+
126
+ # 必要環境變數檢查
127
+ # 這裡先拿掉
128
+ # if SUBSIDY_LINE_BOT_CHANNEL_SECRET is None:
129
+ # print("Specify SUBSIDY_LINE_BOT_CHANNEL_SECRET as environment variable.")
130
+ # sys.exit(1)
131
+ # if SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN is None:
132
+ # print("Specify SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN as environment variable.")
133
+ # sys.exit(1)
134
+
135
+
136
+ async def log_to_bigquery(
137
+ user_id: str,
138
+ display_name: str,
139
+ action_type: str,
140
+ message: str,
141
+ model: str,
142
+ request: Request,
143
+ resource_id: str = "",
144
+ ):
145
+ """
146
+ 使用 Botrun Logger 記錄訊息到 BigQuery
147
+
148
+ Args:
149
+ user_id (str): LINE 使用者 ID
150
+ display_name (str): 使用者 Line 顯示名稱
151
+ action_type (str): 事件類型
152
+ message (str): 訊息內容
153
+ model (str): 使用的模型
154
+ request (Request): FastAPI request 物件,用於取得 IP 等資訊
155
+ resource_id (str): 資源 ID 預設為空字串
156
+ """
157
+ start_time = time.time()
158
+
159
+ try:
160
+ # 取得 Line Server IP 位址
161
+ line_server_ip = request.client.host
162
+ tz = pytz.timezone("Asia/Taipei")
163
+
164
+ # 建立文字記錄項目
165
+ text_log = TextLogEntry(
166
+ timestamp=datetime.now(tz).strftime("%Y-%m-%dT%H:%M:%SZ"),
167
+ domain_name=os.getenv("DOMAIN_NAME", ""),
168
+ user_department=os.getenv("BOTRUN_LOG_DEPARTMENT", "subsidy_line_bot"),
169
+ user_name=f"{display_name} ({user_id})",
170
+ source_ip=f"{line_server_ip} (Line Server)",
171
+ session_id="",
172
+ action_type=action_type,
173
+ developer="subsidy_line_bot_elan",
174
+ action_details=message,
175
+ model=model,
176
+ botrun="subsidy_line_bot",
177
+ user_agent="",
178
+ resource_id=resource_id,
179
+ )
180
+
181
+ # 插入到 BigQuery
182
+ subsidy_line_bot_bq_logger.insert_text_log(text_log)
183
+
184
+ elapsed_time = time.time() - start_time
185
+ logging.info(
186
+ f"[BigQuery Logger] 記錄使用者 {display_name} ({user_id}) 的 {action_type} 訊息到 BigQuery 成功,耗時 {elapsed_time:.3f}s"
187
+ )
188
+
189
+ except Exception as e:
190
+ elapsed_time = time.time() - start_time
191
+ logging.error(
192
+ f"[BigQuery Logger] 記錄使用者 {display_name} ({user_id}) 的 {action_type} 訊息到 BigQuery 失敗,耗時 {elapsed_time:.3f}s,錯誤: {e}"
193
+ )
194
+
195
+
196
+ def get_prompt_from_google_doc(tag_name: str, fallback_prompt: str = ""):
197
+ """
198
+ 從 Google 文件中提取指定標籤的內容
199
+ 優先從 Google 文件讀取,失敗時回退到指定的 fallback prompt
200
+
201
+ Args:
202
+ tag_name (str): 要搜尋的 XML 標籤名稱 (例如: 'system_prompt', 'related_prompt')
203
+ fallback_prompt (str, optional): 當從 Google 文件讀取失敗時使用的回退內容
204
+
205
+ Returns:
206
+ str: 提取的內容或回退內容
207
+ """
208
+ try:
209
+ # 檢查必要的環境變數是否存在
210
+ credentials_path = os.getenv("GOOGLE_APPLICATION_CREDENTIALS_FOR_BOTRUN_DOC")
211
+ file_id = os.getenv("SUBSIDY_BOTRUN_DOC_FILE_ID")
212
+
213
+ if not credentials_path or not file_id:
214
+ raise ValueError("Missing required environment variables")
215
+
216
+ # 嘗試從 Google 文件讀取
217
+ drive_service, docs_service = authenticate_google_services(credentials_path)
218
+ mime_type = get_google_doc_mime_type(file_id, drive_service)
219
+ file_text = get_google_doc_content_with_service(
220
+ file_id, mime_type, drive_service, with_decode=True
221
+ )
222
+
223
+ # 提取指定標籤的內容
224
+ import re
225
+
226
+ pattern = f"<{tag_name}>(.*?)</{tag_name}>"
227
+ match = re.search(pattern, file_text, re.DOTALL)
228
+ if match:
229
+ logger.info(
230
+ f"[Line Bot Webhook: subsidy_webhook] Successfully extracted {tag_name} from Google Docs"
231
+ )
232
+ if match.group(1).strip():
233
+ return match.group(1).strip()
234
+ else:
235
+ return fallback_prompt
236
+ logger.info(
237
+ f"[Line Bot Webhook: subsidy_webhook] Failed to extract {tag_name} from Google Docs, return file text"
238
+ )
239
+
240
+ return fallback_prompt
241
+
242
+ except Exception as e:
243
+ logger.warning(
244
+ f"[Line Bot Webhook: subsidy_webhook] Failed to load {tag_name} from Google Docs, using fallback. Error: {e}"
245
+ )
246
+
247
+ return fallback_prompt
248
+
249
+
250
+ def get_subsidy_api_system_prompt():
251
+ """
252
+ 取得智津貼的系統提示
253
+ 優先從 Google 文件讀取,失敗時回退到本地檔案
254
+ """
255
+ current_dir = Path(__file__).parent
256
+ fallback_prompt = (current_dir / "subsidy_api_system_prompt.txt").read_text(
257
+ encoding="utf-8"
258
+ )
259
+ return get_prompt_from_google_doc("system_prompt", fallback_prompt)
260
+
261
+
262
+ def get_subsidy_bot_related_prompt():
263
+ """
264
+ 取得智津貼的相關問題提示
265
+ 優先從 Google 文件讀取,失敗時使用預設的相關問題提示
266
+ """
267
+ return get_prompt_from_google_doc("related_prompt", DEFAULT_RELATED_PROMPT)
268
+
269
+
270
+ def get_subsidy_bot_normal_chat_prompt():
271
+ """
272
+ 取得智津貼的正常聊天提示
273
+ 優先從 Google 文件讀取,失敗時使用預設的正常聊天提示
274
+ """
275
+ return get_prompt_from_google_doc("normal_chat_prompt", NORMAL_CHAT_PROMPT_TEXT)
276
+
277
+
278
+ def get_subsidy_bot_requirement_prompt():
279
+ """
280
+ 取得智津貼的 requirement_prompt
281
+ 優先從 Google 文件讀取,失敗時使用預設的必要提示
282
+ """
283
+ return get_prompt_from_google_doc("requirement_prompt", REQUIREMENT_PROMPT_TEMPLATE)
284
+
285
+
286
+ def get_subsidy_bot_search_config() -> dict:
287
+ return {
288
+ **DEFAULT_SEARCH_CONFIG,
289
+ "requirement_prompt": get_subsidy_bot_requirement_prompt(),
290
+ "search_prompt": get_subsidy_api_system_prompt(),
291
+ "normal_chat_prompt": get_subsidy_bot_normal_chat_prompt(),
292
+ "related_prompt": get_subsidy_bot_related_prompt(),
293
+ "domain_filter": ["*.gov.tw", "-*.gov.cn"],
294
+ "user_prompt_prefix": "你是台灣人,你不可以講中國用語也不可以用簡體中文,禁止!你的回答內容不要用Markdown格式。",
295
+ "stream": False,
296
+ }
297
+
298
+
299
+ async def create_botrun_url_to_feedback(event):
300
+ """
301
+ 建立 Botrun URL 以供使用者點擊進行問答
302
+
303
+ Args:
304
+ event: LINE Bot MessageEvent
305
+
306
+ Returns:
307
+ str: Botrun 前端 URL 包含 JWT token
308
+
309
+ Raises:
310
+ HTTPException: 當環境變數未設定或 API 呼叫失敗時
311
+ """
312
+ logging.info(f"[create_botrun_url_to_feedback] Start creating botrun url")
313
+
314
+ # 檢查必要的環境變數
315
+ if not BOTRUN_FRONT_URL:
316
+ error_msg = "BOTRUN_FRONT_URL environment variable is not set"
317
+ logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
318
+ raise HTTPException(status_code=500, detail=error_msg)
319
+
320
+ if not BOTRUN_BACK_API_BASE:
321
+ error_msg = "BOTRUN_BACK_API_BASE environment variable is not set"
322
+ logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
323
+ raise HTTPException(status_code=500, detail=error_msg)
324
+
325
+ if not BOTRUN_BACK_LINE_AUTH_API_TOKEN:
326
+ error_msg = "BOTRUN_BACK_LINE_AUTH_API_TOKEN environment variable is not set"
327
+ logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
328
+ raise HTTPException(status_code=500, detail=error_msg)
329
+
330
+ # 組合 API URL
331
+ api_url = f"{BOTRUN_BACK_API_BASE}/botrun/v2/line/auth/token"
332
+
333
+ # 準備請求參數
334
+ headers = {
335
+ "accept": "application/json",
336
+ "x-api-token": BOTRUN_BACK_LINE_AUTH_API_TOKEN,
337
+ "Content-Type": "application/json"
338
+ }
339
+
340
+ payload = {
341
+ "botrun_id": SUBSIDY_LINE_BOT_BOTRUN_ID,
342
+ "message": event.message.text,
343
+ "token_hours": SUBSIDY_LINE_BOT_JWT_TOKEN_HOURS,
344
+ "user_role": SUBSIDY_LINE_BOT_USER_ROLE,
345
+ "username": event.source.user_id
346
+ }
347
+
348
+ logging.info(f"[create_botrun_url_to_feedback] Calling API: {api_url}")
349
+ logging.info(f"[create_botrun_url_to_feedback] Payload: botrun_id={payload['botrun_id']}, "
350
+ f"token_hours={payload['token_hours']}, user_role={payload['user_role']}, "
351
+ f"username={payload['username']}")
352
+
353
+ try:
354
+ async with aiohttp.ClientSession() as session:
355
+ async with session.post(api_url, headers=headers, json=payload) as response:
356
+ response_text = await response.text()
357
+
358
+ if response.status != 200:
359
+ error_msg = f"API returned status {response.status}: {response_text}"
360
+ logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
361
+ raise HTTPException(
362
+ status_code=500,
363
+ detail=f"Failed to get authentication token from Botrun API"
364
+ )
365
+
366
+ try:
367
+ response_data = json.loads(response_text)
368
+ except json.JSONDecodeError:
369
+ error_msg = f"Invalid JSON response: {response_text}"
370
+ logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
371
+ raise HTTPException(
372
+ status_code=500,
373
+ detail="Invalid response format from Botrun API"
374
+ )
375
+
376
+ # 檢查 API 回應是否成功
377
+ if not response_data.get("success", False):
378
+ error_code = response_data.get("error_code", "UNKNOWN")
379
+ error_message = response_data.get("error_message", "Unknown error")
380
+ error_msg = f"API returned error: {error_code} - {error_message}"
381
+ logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
382
+ raise HTTPException(
383
+ status_code=500,
384
+ detail=f"Botrun API error: {error_message}"
385
+ )
386
+
387
+ # 取得 session_id
388
+ session_id = response_data.get("session_id")
389
+ if not session_id:
390
+ error_msg = "No session_id in API response"
391
+ logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
392
+ raise HTTPException(
393
+ status_code=500,
394
+ detail="Failed to get session ID from Botrun API"
395
+ )
396
+
397
+ # 取得 access_token
398
+ access_token = response_data.get("access_token")
399
+ if not access_token:
400
+ error_msg = "No access_token in API response"
401
+ logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
402
+ raise HTTPException(
403
+ status_code=500,
404
+ detail="Failed to get access token from Botrun API"
405
+ )
406
+
407
+ # 組合最終的 URL
408
+ # 確保 URL 不會有雙斜線
409
+ front_url = BOTRUN_FRONT_URL.rstrip("/")
410
+ botrun_url = f"{front_url}/b/{SUBSIDY_LINE_BOT_BOTRUN_ID}/s/{session_id}?external=true&hideBotrunHatch=true&hideUserInfo=true&botrun_token={access_token}"
411
+
412
+ logging.info(f"[create_botrun_url_to_feedback] Successfully created botrun URL")
413
+ logging.info(f"[create_botrun_url_to_feedback] Session ID: {response_data.get('session_id')}")
414
+
415
+ return botrun_url
416
+
417
+ except aiohttp.ClientError as e:
418
+ error_msg = f"Network error calling Botrun API: {str(e)}"
419
+ logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
420
+ raise HTTPException(
421
+ status_code=500,
422
+ detail="Failed to connect to Botrun API"
423
+ )
424
+ except HTTPException:
425
+ # 重新拋出 HTTPException
426
+ raise
427
+ except Exception as e:
428
+ error_msg = f"Unexpected error: {str(e)}"
429
+ logging.error(f"[create_botrun_url_to_feedback] {error_msg}")
430
+ logging.error(traceback.format_exc())
431
+ raise HTTPException(
432
+ status_code=500,
433
+ detail="An unexpected error occurred"
434
+ )
435
+
436
+
437
+ @router.post("/subsidy/webhook")
438
+ async def subsidy_webhook(request: Request):
439
+ from linebot.v3.exceptions import InvalidSignatureError
440
+ from linebot.v3.webhook import WebhookParser
441
+ from linebot.v3.messaging import AsyncApiClient, Configuration
442
+
443
+ signature = request.headers["X-Line-Signature"]
444
+ if SUBSIDY_LINE_BOT_CHANNEL_SECRET is None:
445
+ raise HTTPException(
446
+ status_code=500, detail="SUBSIDY_LINE_BOT_CHANNEL_SECRET is not set"
447
+ )
448
+ if SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN is None:
449
+ raise HTTPException(
450
+ status_code=500, detail="SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN is not set"
451
+ )
452
+ parser = WebhookParser(SUBSIDY_LINE_BOT_CHANNEL_SECRET)
453
+ configuration = Configuration(access_token=SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN)
454
+
455
+ # get request body as text
456
+ body = await request.body()
457
+ body_str = body.decode("utf-8")
458
+ body_json = json.loads(body_str)
459
+ logging.info(
460
+ "[Line Bot Webhook: subsidy_webhook] Received webhook: %s",
461
+ json.dumps(body_json, indent=2, ensure_ascii=False),
462
+ )
463
+
464
+ try:
465
+ events = parser.parse(body_str, signature)
466
+ except InvalidSignatureError:
467
+ raise HTTPException(status_code=400, detail="Invalid signature")
468
+
469
+ start = time.time()
470
+ env_name = os.getenv("ENV_NAME", "botrun-flow-lang-dev")
471
+ subsidy_line_bot_graph = SearchAgentGraph(
472
+ memory=AsyncFirestoreCheckpointer(env_name=env_name)
473
+ ).graph
474
+ logging.info(
475
+ f"[Line Bot Webhook: subsidy_webhook] init graph took {time.time() - start:.3f}s"
476
+ )
477
+
478
+ responses = []
479
+ async with AsyncApiClient(configuration) as async_api_client:
480
+ line_bot_api = AsyncMessagingApi(async_api_client)
481
+ # logging.info(f"[line_bot_api] subsidy_webhook / len(events): {len(events)}")
482
+ for event in events:
483
+ # 處理使用者傳送詢問訊息的事件
484
+ if isinstance(event, MessageEvent) and isinstance(
485
+ event.message, TextMessageContent
486
+ ):
487
+ # response = await handle_message(
488
+ # event,
489
+ # line_bot_api,
490
+ # RATE_LIMIT_WINDOW,
491
+ # RATE_LIMIT_COUNT,
492
+ # subsidy_line_bot_graph,
493
+ # request,
494
+ # )
495
+ logging.info("[handle_message] Start handling message event")
496
+ from linebot.v3.messaging import (
497
+ ReplyMessageRequest,
498
+ TextMessage,
499
+ )
500
+
501
+ try:
502
+ botrun_url = await create_botrun_url_to_feedback(event)
503
+ await line_bot_api.reply_message(
504
+ ReplyMessageRequest(
505
+ reply_token=event.reply_token,
506
+ messages=[TextMessage(text=f"訊息收到了,請用以下連結進行問答:\n{botrun_url}")],
507
+ )
508
+ )
509
+ responses.append({"status": "success", "url": botrun_url})
510
+
511
+ except HTTPException as e:
512
+ # 處理 create_botrun_url_to_feedback 拋出的 HTTPException
513
+ error_message = "很抱歉,系統暫時無法處理您的訊息,請稍後再試。"
514
+ logging.error(f"[subsidy_webhook] Failed to create botrun URL: {e.detail}")
515
+
516
+ # 回覆使用者錯誤訊息
517
+ await line_bot_api.reply_message(
518
+ ReplyMessageRequest(
519
+ reply_token=event.reply_token,
520
+ messages=[TextMessage(text=error_message)],
521
+ )
522
+ )
523
+ responses.append({"status": "error", "error": str(e.detail)})
524
+
525
+ except Exception as e:
526
+ # 處理其他未預期的錯誤
527
+ error_message = "很抱歉,系統發生錯誤,請稍後再試。"
528
+ logging.error(f"[subsidy_webhook] Unexpected error: {str(e)}")
529
+ logging.error(traceback.format_exc())
530
+
531
+ # 回覆使用者錯誤訊息
532
+ await line_bot_api.reply_message(
533
+ ReplyMessageRequest(
534
+ reply_token=event.reply_token,
535
+ messages=[TextMessage(text=error_message)],
536
+ )
537
+ )
538
+ responses.append({"status": "error", "error": str(e)})
539
+
540
+ # 處理使用者藉由按讚反讚按鈕反饋的postback事件
541
+ elif isinstance(event, PostbackEvent):
542
+ # await handle_feedback(event, line_bot_api, subsidy_line_bot_graph)
543
+ responses.append("feedback_handled")
544
+
545
+ return {"responses": responses}
546
+
547
+
548
+ async def get_user_display_name(user_id: str, line_bot_api: AsyncMessagingApi) -> str:
549
+ """
550
+ 取得使用者的Line顯示名稱
551
+
552
+ Args:
553
+ user_id (str): 使用者ID
554
+ line_bot_api (AsyncMessagingApi): LINE Bot API 客戶端
555
+
556
+ Returns:
557
+ user_display_name (str): 使用者的Line顯示名稱
558
+ """
559
+ try:
560
+ user_profile = await line_bot_api.get_profile(user_id)
561
+ return user_profile.display_name
562
+ except Exception as e:
563
+ logging.error(
564
+ f"[Line Bot Webhook: get_user_display_name] 無法取得使用者 {user_id} 的顯示名稱: {str(e)}"
565
+ )
566
+
567
+
568
+ async def handle_message(
569
+ event: MessageEvent,
570
+ line_bot_api: AsyncMessagingApi,
571
+ rate_limit_window: int,
572
+ rate_limit_count: int,
573
+ line_bot_graph: SearchAgentGraph,
574
+ request: Request,
575
+ ):
576
+ """處理 LINE Bot 的訊息事件
577
+
578
+ 處理使用者傳送的文字訊息,包括頻率限制檢查、訊息分段與回覆等操作
579
+
580
+ Args:
581
+ event (MessageEvent): LINE Bot 的訊息事件
582
+ line_bot_api (AsyncMessagingApi): LINE Bot API 客戶端
583
+ rate_limit_window (int): 訊息頻率限制時間窗口(秒)
584
+ rate_limit_count (int): 訊息頻率限制數量
585
+ line_bot_graph (SearchAgentGraph): LINE Bot 的 agent graph
586
+ request (Request): FastAPI request 物件,用於記錄到 BigQuery
587
+ """
588
+ start = time.time()
589
+ logging.info(
590
+ "[Line Bot Webhook: handle_message] Enter handle_message for event type: %s",
591
+ event.type,
592
+ )
593
+ from linebot.v3.messaging import (
594
+ ReplyMessageRequest,
595
+ TextMessage,
596
+ FlexMessage,
597
+ FlexBubble,
598
+ FlexBox,
599
+ FlexText,
600
+ FlexButton,
601
+ MessageAction,
602
+ QuickReply,
603
+ QuickReplyItem,
604
+ PostbackAction,
605
+ )
606
+
607
+ # 已經移至常量部分定義
608
+ user_id = event.source.user_id
609
+ user_message = event.message.text
610
+ display_name = await get_user_display_name(user_id, line_bot_api)
611
+ logging.info(
612
+ f"[Line Bot Webhook: handle_message] 收到來自 {display_name} ({user_id}) 的訊息"
613
+ )
614
+
615
+ # 背景記錄使用者訊息到 BigQuery (不等待完成,避免影響回應速度)
616
+ asyncio.create_task(
617
+ log_to_bigquery(
618
+ user_id,
619
+ display_name,
620
+ "llm_input",
621
+ user_message,
622
+ DEFAULT_MODEL_NAME,
623
+ request,
624
+ )
625
+ )
626
+
627
+ if user_message.lower().strip() == "reset":
628
+ env_name = os.getenv("ENV_NAME", "botrun-flow-lang-dev")
629
+ await AsyncFirestoreCheckpointer(env_name=env_name).adelete_thread(user_id)
630
+ await line_bot_api.reply_message(
631
+ ReplyMessageRequest(
632
+ reply_token=event.reply_token,
633
+ messages=[TextMessage(text="已清除記憶,請重新開始對話")],
634
+ )
635
+ )
636
+ return {"message": "已清除記憶,請重新開始對話"}
637
+
638
+ if user_id in _processing_users:
639
+ logging.info(
640
+ f"[Line Bot Webhook: handle_message] 使用者 {display_name} ({user_id}) 已有處理中的訊息,回覆等待提示"
641
+ )
642
+ reply_text = "您的上一條訊息正在處理中,請稍候再發送新訊息"
643
+ await line_bot_api.reply_message(
644
+ ReplyMessageRequest(
645
+ reply_token=event.reply_token,
646
+ messages=[TextMessage(text=reply_text)],
647
+ )
648
+ )
649
+ return {"message": reply_text}
650
+
651
+ # 檢查使用者是否超過訊息頻率限制
652
+ is_rate_limited, wait_seconds = check_rate_limit(
653
+ user_id, rate_limit_window, rate_limit_count
654
+ )
655
+ if is_rate_limited:
656
+ logging.info(
657
+ f"[Line Bot Webhook: handle_message] 使用者 {display_name} ({user_id}) 超過訊息頻率限制,需等待 {wait_seconds} 秒"
658
+ )
659
+
660
+ # 回覆頻率限制提示
661
+ window_minutes = rate_limit_window // 60
662
+ wait_minutes = max(1, wait_seconds // 60)
663
+ reply_text = f"您發送訊息的頻率過高,{window_minutes}分鐘內最多可發送{rate_limit_count}則訊息。請等待約 {wait_minutes} 分鐘後再試。"
664
+ await line_bot_api.reply_message(
665
+ ReplyMessageRequest(
666
+ reply_token=event.reply_token,
667
+ messages=[TextMessage(text=reply_text)],
668
+ )
669
+ )
670
+ return {"message": reply_text}
671
+
672
+ # 標記使用者為處理中
673
+ _processing_users.add(user_id)
674
+
675
+ try:
676
+ reply_text, related_questions = await get_reply_text(
677
+ line_bot_graph, user_message, user_id, display_name, request
678
+ )
679
+ logging.info(
680
+ f"[Line Bot Webhook: handle_message] Total response length: {len(reply_text)}"
681
+ )
682
+
683
+ # 將長訊息分段,每段不超過 LINE_MAX_MESSAGE_LENGTH
684
+ message_chunks = []
685
+ remaining_text = reply_text
686
+
687
+ while remaining_text:
688
+ # 如果剩餘文字長度在限制內,直接加入並結束
689
+ if len(remaining_text) <= LINE_MAX_MESSAGE_LENGTH:
690
+ message_chunks.append(remaining_text)
691
+ logging.info(
692
+ f"[Line Bot Webhook: handle_message] Last chunk length: {len(remaining_text)}"
693
+ )
694
+ break
695
+
696
+ # 確保分段大小在限制內
697
+ safe_length = min(
698
+ LINE_MAX_MESSAGE_LENGTH - 100, len(remaining_text)
699
+ ) # 預留一些空間
700
+
701
+ # 在安全長度內尋找最後一個完整句子
702
+ chunk_end = safe_length
703
+ for i in range(safe_length - 1, max(0, safe_length - 200), -1):
704
+ if remaining_text[i] in "。!?!?":
705
+ chunk_end = i + 1
706
+ break
707
+
708
+ # 如果找不到適合的句子結尾,就用空格或換行符號來分割
709
+ if chunk_end == safe_length:
710
+ for i in range(safe_length - 1, max(0, safe_length - 200), -1):
711
+ if remaining_text[i] in " \n":
712
+ chunk_end = i + 1
713
+ break
714
+ # 如果還是找不到合適的分割點,就直接在安全長度處截斷
715
+ if chunk_end == safe_length:
716
+ chunk_end = safe_length
717
+
718
+ # 加入這一段文字
719
+ current_chunk = remaining_text[:chunk_end]
720
+ logging.info(
721
+ f"[Line Bot Webhook: handle_message] Current chunk length: {len(current_chunk)}"
722
+ )
723
+ message_chunks.append(current_chunk)
724
+
725
+ # 更新剩餘文字
726
+ remaining_text = remaining_text[chunk_end:]
727
+
728
+ logging.info(
729
+ f"[Line Bot Webhook: handle_message] Number of chunks: {len(message_chunks)}"
730
+ )
731
+ for i, chunk in enumerate(message_chunks):
732
+ logging.info(
733
+ f"[Line Bot Webhook: handle_message] Chunk {i} length: {len(chunk)}"
734
+ )
735
+
736
+ # 創建訊息列表
737
+ messages = []
738
+
739
+ # 添加所有文字訊息區塊
740
+ for i, chunk in enumerate(message_chunks):
741
+ messages.append(TextMessage(text=chunk))
742
+
743
+ # 添加相關問題按鈕
744
+ question_bubble = None
745
+ if related_questions:
746
+ title = FlexText(
747
+ text="以下是您可能想要了解的相關問題:",
748
+ weight="bold",
749
+ size="md",
750
+ wrap=True,
751
+ )
752
+ buttons = [
753
+ FlexButton(
754
+ action=MessageAction(label=q[:20], text=q),
755
+ style="secondary",
756
+ margin="sm",
757
+ height="sm",
758
+ scaling=True,
759
+ adjust_mode="shrink-to-fit",
760
+ )
761
+ for q in related_questions
762
+ ]
763
+ question_bubble = FlexBubble(
764
+ body=FlexBox(
765
+ layout="vertical", spacing="sm", contents=[title, *buttons]
766
+ )
767
+ )
768
+
769
+ # 以 Quick Reply 作為按讚反讚按鈕
770
+ quick_reply = QuickReply(
771
+ items=[
772
+ QuickReplyItem(
773
+ action=PostbackAction(
774
+ label="津好康,真是棒👍🏻",
775
+ data="實用",
776
+ display_text="津好康,真是棒👍🏻",
777
+ )
778
+ ),
779
+ QuickReplyItem(
780
+ action=PostbackAction(
781
+ label="津可惜,不太實用😖",
782
+ data="不實用",
783
+ display_text="津可惜,不太實用😖",
784
+ )
785
+ ),
786
+ ]
787
+ )
788
+
789
+ if question_bubble:
790
+ messages.append(FlexMessage(alt_text="相關問題", contents=question_bubble))
791
+
792
+ messages[-1].quick_reply = quick_reply
793
+
794
+ await line_bot_api.reply_message(
795
+ ReplyMessageRequest(reply_token=event.reply_token, messages=messages)
796
+ )
797
+ except Exception as e:
798
+ logging.error(
799
+ f"[Line Bot Webhook: handle_message] 處理使用者 {display_name} ({user_id}) 訊息時發生錯誤: {e}"
800
+ )
801
+ traceback.print_exc()
802
+ reply_text = "很抱歉,處理您的訊息時遇到問題,請稍後再試"
803
+ try:
804
+ await line_bot_api.reply_message(
805
+ ReplyMessageRequest(
806
+ reply_token=event.reply_token,
807
+ messages=[TextMessage(text=reply_text)],
808
+ )
809
+ )
810
+ except Exception as reply_error:
811
+ logging.error(
812
+ f"[Line Bot Webhook: handle_message] 無法發送錯誤回覆: {reply_error}"
813
+ )
814
+ traceback.print_exc()
815
+ finally:
816
+ logging.info(
817
+ f"[Line Bot Webhook: handle_message] total elapsed {time.time() - start:.3f}s"
818
+ )
819
+ _processing_users.discard(user_id)
820
+ logging.info(
821
+ f"[Line Bot Webhook: handle_message] 使用者 {display_name} ({user_id}) 的訊息處理完成"
822
+ )
823
+
824
+ return {"message": reply_text}
825
+
826
+
827
+ def check_rate_limit(user_id: str, window: int, count: int) -> Tuple[bool, int]:
828
+ """檢查使用者是否超過訊息頻率限制
829
+
830
+ 檢查使用者在指定時間窗口內發送的訊息數量是否超過限制。
831
+ 同時清理過期的時間戳記,以避免記憶體無限增長。
832
+
833
+ Args:
834
+ user_id (str): 使用者的 LINE ID
835
+ window (int): 時間窗口(秒)
836
+ count (int): 訊息數量限制
837
+
838
+ Returns:
839
+ Tuple[bool, int]: (是否超過限制, 需要等待的秒數)
840
+ 如果未超過限制,第二個值為 0
841
+ """
842
+ current_time = time.time()
843
+ user_timestamps = _user_message_timestamps[user_id]
844
+
845
+ # 清理過期的時間戳記(超過時間窗口的)
846
+ while user_timestamps and current_time - user_timestamps[0] > window:
847
+ user_timestamps.popleft()
848
+
849
+ # 如果清理後沒有時間戳記,則從字典中移除該使用者的記錄
850
+ if not user_timestamps:
851
+ del _user_message_timestamps[user_id]
852
+ # 如果使用者沒有有效的時間戳記,則直接添加新的時間戳記
853
+ _user_message_timestamps[user_id].append(current_time)
854
+ return False, 0
855
+
856
+ # 檢查是否超過限制
857
+ if len(user_timestamps) >= count:
858
+ # 計算需要等待的時間
859
+ oldest_timestamp = user_timestamps[0]
860
+ wait_time = int(window - (current_time - oldest_timestamp))
861
+ return True, max(0, wait_time)
862
+
863
+ # 未超過限制,添加當前時間戳記
864
+ user_timestamps.append(current_time)
865
+
866
+ return False, 0
867
+
868
+
869
+ async def get_reply_text(
870
+ line_bot_graph,
871
+ line_user_message: str,
872
+ user_id: str,
873
+ display_name: str,
874
+ request: Request,
875
+ ) -> tuple[str, list]:
876
+ """
877
+ 使用 agent_runner 處理使用者訊息並回傳回覆內容
878
+
879
+ Args:
880
+ line_bot_graph (SearchAgentGraph): LINE Bot 的 agent graph
881
+ line_user_message (str): 使用者傳送的 LINE 訊息內容
882
+ user_id (str): 使用者的 LINE ID
883
+ display_name (str): 使用者的 Line 顯示名稱
884
+ request (Request): FastAPI request 物件,用於記錄到 BigQuery
885
+
886
+ Returns:
887
+ tuple[str, list]: 包含回覆訊息和相關問題的元組
888
+ """
889
+ start_time = time.time()
890
+ full_response = ""
891
+ chat_model_events = [] # 收集 ChatModelEndEvent
892
+
893
+ async for event_chunk in agent_runner(
894
+ user_id,
895
+ {"messages": [line_user_message]},
896
+ line_bot_graph,
897
+ extra_config=get_subsidy_bot_search_config(),
898
+ ):
899
+ if isinstance(event_chunk, OnNodeStreamEvent):
900
+ # 處理串流文字事件
901
+ full_response += event_chunk.chunk
902
+ if isinstance(event_chunk, ChatModelEndEvent):
903
+ # 收集 ChatModelEndEvent 待後續處理
904
+ chat_model_events.append(event_chunk)
905
+
906
+ # 迴圈結束後,處理所有收集到的 ChatModelEndEvent 並記錄到 BigQuery
907
+ for event_chunk in chat_model_events:
908
+ try:
909
+ # 使用輔助函數處理事件資料
910
+ ai_message_outputs = _extract_ai_message_outputs(
911
+ event_chunk.raw_output, event_chunk.langgraph_node
912
+ )
913
+ inputs = _extract_input_messages(event_chunk.raw_input)
914
+
915
+ # 處理節點名稱映射
916
+ processed_node_name = event_chunk.langgraph_node
917
+ if processed_node_name == "extract":
918
+ processed_node_name = "requirement_node_extract"
919
+
920
+ # 準備資源 ID
921
+ resource_id = ""
922
+ if event_chunk.usage_metadata:
923
+ resource_id = json.dumps(event_chunk.usage_metadata, ensure_ascii=False)
924
+
925
+ # 準備要記錄的訊息內容
926
+ log_message_parts = []
927
+ if ai_message_outputs:
928
+ log_message_parts.append(ai_message_outputs)
929
+ if inputs:
930
+ inputs_text = "".join(inputs)
931
+ log_message_parts.append(inputs_text)
932
+
933
+ log_message = "".join(log_message_parts)
934
+
935
+ logging.info(
936
+ f"[Line Bot Webhook: get_reply_text - collect log info for BigQuery] langgraph_node: {processed_node_name}"
937
+ )
938
+ logging.info(
939
+ f"[Line Bot Webhook: get_reply_text - collect log info for BigQuery] model_name: {event_chunk.model_name}"
940
+ )
941
+ logging.info(
942
+ f"[Line Bot Webhook: get_reply_text - collect log info for BigQuery] resource_id: {resource_id}"
943
+ )
944
+ logging.info(
945
+ f"[Line Bot Webhook: get_reply_text - collect log info for BigQuery] ai_message_outputs: {ai_message_outputs}"
946
+ )
947
+ for i, input in enumerate(inputs, start=1):
948
+ logging.info(
949
+ f"[Line Bot Webhook: get_reply_text - collect log info for BigQuery] inputs_{i}: {input}"
950
+ )
951
+
952
+ logging.info(
953
+ f"[Line Bot Webhook: get_reply_text - collect log info for BigQuery] log_message: {log_message}"
954
+ )
955
+
956
+ # 異步記錄到 BigQuery
957
+ asyncio.create_task(
958
+ log_to_bigquery(
959
+ user_id=user_id,
960
+ display_name=display_name,
961
+ action_type=f"langgraph_agent_api-[{processed_node_name}]",
962
+ message=log_message,
963
+ model=event_chunk.model_name,
964
+ request=request,
965
+ resource_id=resource_id,
966
+ )
967
+ )
968
+
969
+ logging.info(
970
+ f"[Line Bot Webhook: get_reply_text] Logged ChatModelEndEvent to BigQuery for user {user_id}"
971
+ )
972
+
973
+ except Exception as e:
974
+ logging.error(
975
+ f"[Line Bot Webhook: get_reply_text] Failed to log ChatModelEndEvent to BigQuery: {e}"
976
+ )
977
+
978
+ # 記錄 LLM 輸出到 BigQuery
979
+ asyncio.create_task(
980
+ log_to_bigquery(
981
+ user_id,
982
+ display_name,
983
+ "llm_output",
984
+ full_response,
985
+ DEFAULT_MODEL_NAME,
986
+ request,
987
+ )
988
+ )
989
+
990
+ if "</think>" in full_response:
991
+ full_response = full_response.split("</think>", 1)[1].lstrip()
992
+
993
+ full_response += "\n" + os.getenv("SUBSIDY_LINEBOT_FOOTNOTE", "")
994
+
995
+ # 取得相關問題但不附加到回覆內容
996
+ related_questions = []
997
+ try:
998
+ # 嘗試使用非同步方式取得 state(若 checkpointer 為非同步型別)
999
+ try:
1000
+ state_obj = await line_bot_graph.aget_state(
1001
+ {"configurable": {"thread_id": user_id}}
1002
+ )
1003
+ except AttributeError:
1004
+ # 回退到同步方法
1005
+ state_obj = line_bot_graph.get_state(
1006
+ {"configurable": {"thread_id": user_id}}
1007
+ )
1008
+
1009
+ # 根據返回型別(dict 或具備屬性)解析
1010
+ if isinstance(state_obj, dict):
1011
+ related_questions = state_obj.get("related_questions", [])
1012
+ elif hasattr(state_obj, "related_questions"):
1013
+ related_questions = getattr(state_obj, "related_questions", [])
1014
+ elif hasattr(state_obj, "values") and isinstance(state_obj.values, dict):
1015
+ related_questions = state_obj.values.get("related_questions", [])
1016
+ except Exception as e:
1017
+ logging.error(
1018
+ f"[Line Bot Webhook: get_reply_text] Failed to append related questions: {e}"
1019
+ )
1020
+
1021
+ logging.info(
1022
+ f"[Line Bot Webhook: get_reply_text] total took {time.time() - start_time:.3f}s"
1023
+ )
1024
+
1025
+ return full_response, related_questions
1026
+
1027
+
1028
+ async def handle_feedback(
1029
+ event: PostbackEvent,
1030
+ line_bot_api: AsyncMessagingApi,
1031
+ subsidy_line_bot_graph,
1032
+ ):
1033
+ """處理使用者透過 Quick Reply 按鈕提供的回饋
1034
+
1035
+ Args:
1036
+ event (PostbackEvent): LINE Bot 的 postback 事件
1037
+ line_bot_api (AsyncMessagingApi): LINE Bot API 客戶端
1038
+ subsidy_line_bot_graph: LINE Bot 的 graph 實例,用來取得對話歷史
1039
+ """
1040
+ try:
1041
+ user_id = event.source.user_id
1042
+ feedback_data = event.postback.data
1043
+ display_name = await get_user_display_name(user_id, line_bot_api)
1044
+
1045
+ taiwan_tz = pytz.timezone("Asia/Taipei")
1046
+ current_time = datetime.now(taiwan_tz)
1047
+ formatted_time = current_time.strftime("%Y-%m-%d %H:%M:%S")
1048
+
1049
+ # 從 graph state 中取得對話歷史
1050
+ config = {"configurable": {"thread_id": user_id}}
1051
+ try:
1052
+ from langchain_core.messages import HumanMessage, AIMessage
1053
+
1054
+ state = await subsidy_line_bot_graph.aget_state(config)
1055
+ messages = state.values.get("messages", [])
1056
+
1057
+ # 找到最新的使用者提問和 AI 回答
1058
+ latest_user_question = ""
1059
+ latest_ai_response = ""
1060
+
1061
+ # 從後往前查找最新的對話
1062
+ for i in range(len(messages) - 1, -1, -1):
1063
+ message = messages[i]
1064
+ # 檢查是否為 AI 訊息且不是工具呼叫
1065
+ if isinstance(message, AIMessage) and not getattr(
1066
+ message, "tool_calls", None
1067
+ ):
1068
+ if not latest_ai_response:
1069
+ latest_ai_response = str(message.content)
1070
+ elif isinstance(message, HumanMessage):
1071
+ if not latest_user_question:
1072
+ latest_user_question = str(message.content)
1073
+ # 如果已經找到最新的使用者問題,就停止搜尋
1074
+ if latest_ai_response:
1075
+ break
1076
+
1077
+ except Exception as e:
1078
+ logging.error(f"[Line Bot Webhook: handle_feedback] 無法取得對話歷史: {e}")
1079
+ latest_user_question = "無法取得"
1080
+ latest_ai_response = "無法取得"
1081
+
1082
+ if "</think>" in latest_ai_response:
1083
+ latest_ai_response = latest_ai_response.split("</think>", 1)[1].lstrip()
1084
+
1085
+ # 記錄詳細的回饋資訊
1086
+ logging.info(
1087
+ f"[Line Bot Webhook: handle_feedback] 回饋詳細資訊:\n"
1088
+ f" 建立時間: {formatted_time}\n"
1089
+ f" 使用者ID: {user_id}\n"
1090
+ f" 使用者Line顯示名稱: {display_name}\n"
1091
+ f" 使用者輸入: {latest_user_question}\n"
1092
+ f" LineBot回應: {latest_ai_response}\n"
1093
+ f" 反饋: {feedback_data}"
1094
+ )
1095
+
1096
+ # 先回覆使用者已收到回饋的訊息
1097
+ from linebot.v3.messaging import TextMessage, ReplyMessageRequest
1098
+
1099
+ reply_text = "已收到您的回饋。"
1100
+ await line_bot_api.reply_message(
1101
+ ReplyMessageRequest(
1102
+ reply_token=event.reply_token,
1103
+ messages=[TextMessage(text=reply_text)],
1104
+ )
1105
+ )
1106
+
1107
+ # 使用 asyncio.create_task 在背景執行更新使用者回饋到 Google Sheet
1108
+ feedback_dict = {
1109
+ "建立時間": formatted_time,
1110
+ "使用者ID": user_id,
1111
+ "使用者Line顯示名稱": display_name,
1112
+ "使用者輸入": latest_user_question,
1113
+ "LineBot回應": latest_ai_response,
1114
+ "反饋": feedback_data,
1115
+ }
1116
+ asyncio.create_task(update_feedback_to_gsheet(feedback_dict))
1117
+ except Exception as e:
1118
+ logging.error(
1119
+ f"[Line Bot Webhook: handle_feedback] 處理使用者 {display_name} ({user_id}) 回饋時發生錯誤: {e}"
1120
+ )
1121
+ traceback.print_exc()
1122
+
1123
+
1124
+ async def update_feedback_to_gsheet(feedback_data: dict):
1125
+ """更新回饋資料到 Google Sheets"""
1126
+ try:
1127
+ service_account_file = os.getenv(
1128
+ "GOOGLE_APPLICATION_CREDENTIALS_FOR_SUBSIDY_LINEBOT"
1129
+ )
1130
+ spreadsheet_id = os.getenv("SUBSIDY_LINEBOT_GSPREAD_ID")
1131
+
1132
+ if not service_account_file:
1133
+ logging.error(
1134
+ "[Line Bot Webhook: update_feedback_to_gsheet] 環境變數 GOOGLE_APPLICATION_CREDENTIALS_FOR_SUBSIDY_LINEBOT 未設定"
1135
+ )
1136
+ return
1137
+
1138
+ if not spreadsheet_id:
1139
+ logging.error(
1140
+ "[Line Bot Webhook: update_feedback_to_gsheet] 環境變數 SUBSIDY_LINEBOT_GSPREAD_ID 未設定"
1141
+ )
1142
+ return
1143
+
1144
+ if not os.path.exists(service_account_file):
1145
+ logging.error(
1146
+ f"[Line Bot Webhook: update_feedback_to_gsheet] 服務帳戶檔案不存在: {service_account_file}"
1147
+ )
1148
+ return
1149
+
1150
+ worksheet_name = "LineBot意見回饋"
1151
+ headers = [
1152
+ "建立時間",
1153
+ "使用者ID",
1154
+ "使用者Line顯示名稱",
1155
+ "使用者輸入",
1156
+ "LineBot回應",
1157
+ "反饋",
1158
+ ]
1159
+
1160
+ success = create_sheet_if_not_exists(
1161
+ service_account_file=service_account_file,
1162
+ spreadsheet_id=spreadsheet_id,
1163
+ sheet_name=worksheet_name,
1164
+ headers=headers,
1165
+ )
1166
+
1167
+ if not success:
1168
+ logging.error(
1169
+ "[Line Bot Webhook: update_feedback_to_gsheet] 無法建立或存取工作表"
1170
+ )
1171
+ return
1172
+
1173
+ result = append_data_to_gsheet(
1174
+ service_account_file=service_account_file,
1175
+ spreadsheet_id=spreadsheet_id,
1176
+ sheet_name=worksheet_name,
1177
+ data_dict=feedback_data,
1178
+ )
1179
+
1180
+ logging.info(
1181
+ f"[Line Bot Webhook: update_feedback_to_gsheet] 已成功將使用者回饋寫入 Google Sheet {worksheet_name}"
1182
+ )
1183
+
1184
+ except Exception as e:
1185
+ logging.error(
1186
+ f"[Line Bot Webhook: update_feedback_to_gsheet] 將使用者回饋寫入 Google Sheet 時發生錯誤: {e}"
1187
+ )
1188
+ import traceback
1189
+
1190
+ traceback.print_exc()
1191
+
1192
+
1193
+ class MulticastMessage(BaseModel):
1194
+ message: str
1195
+
1196
+
1197
+ @router.post("/subsidy/multicast_msg", dependencies=[Depends(verify_token)])
1198
+ async def subsidy_multicast_msg(body: MulticastMessage):
1199
+ """
1200
+ 透過 LINE Multicast API 將文字訊息一次推播給 Google Sheet「LineBot使用者ID表」中的所有使用者。
1201
+
1202
+ 請以 JSON 格式提供要推播的訊息:{ "message": "要推播的訊息" }
1203
+ """
1204
+ try:
1205
+ text = body.message
1206
+ if not text:
1207
+ raise HTTPException(
1208
+ status_code=400, detail="Request JSON must contain 'message'"
1209
+ )
1210
+
1211
+ # 檢查 Access Token
1212
+ if SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN is None:
1213
+ raise HTTPException(
1214
+ status_code=500,
1215
+ detail="SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN is not set",
1216
+ )
1217
+
1218
+ # 取得 Google Sheet 設定
1219
+ service_account_file = os.getenv(
1220
+ "GOOGLE_APPLICATION_CREDENTIALS_FOR_SUBSIDY_LINEBOT"
1221
+ )
1222
+ spreadsheet_id = os.getenv("SUBSIDY_LINEBOT_GSPREAD_ID")
1223
+ if not service_account_file or not spreadsheet_id:
1224
+ raise HTTPException(status_code=500, detail="Google Sheet env vars not set")
1225
+
1226
+ sheet_name = "LineBot使用者ID表"
1227
+ try:
1228
+ sheet_content = get_sheet_content(
1229
+ service_account_file, spreadsheet_id, sheet_name
1230
+ )
1231
+ except Exception as e:
1232
+ logging.error(f"[Line Bot Multicast] Failed to read Google Sheet: {e}")
1233
+ raise HTTPException(
1234
+ status_code=500,
1235
+ detail="Failed to read user list from Google Sheet",
1236
+ )
1237
+
1238
+ if "user_id" not in sheet_content:
1239
+ raise HTTPException(
1240
+ status_code=400, detail="Sheet missing 'user_id' column"
1241
+ )
1242
+
1243
+ user_ids = sheet_content.get("user_id", [])
1244
+
1245
+ logging.info(
1246
+ f"[Line Bot Multicast] Retrieved {len(user_ids)} user_ids: {user_ids}"
1247
+ )
1248
+
1249
+ if not user_ids:
1250
+ raise HTTPException(status_code=400, detail="No user IDs to send")
1251
+
1252
+ from linebot.v3.messaging import (
1253
+ AsyncApiClient,
1254
+ Configuration,
1255
+ TextMessage,
1256
+ MulticastRequest,
1257
+ )
1258
+
1259
+ configuration = Configuration(
1260
+ access_token=SUBSIDY_LINE_BOT_CHANNEL_ACCESS_TOKEN
1261
+ )
1262
+ async with AsyncApiClient(configuration) as async_api_client:
1263
+ line_bot_api = AsyncMessagingApi(async_api_client)
1264
+ CHUNK_SIZE = 500 # LINE Multicast 單次最多 500 個使用者
1265
+ for i in range(0, len(user_ids), CHUNK_SIZE):
1266
+ chunk_ids = user_ids[i : i + CHUNK_SIZE]
1267
+ multicast_request = MulticastRequest(
1268
+ to=chunk_ids, messages=[TextMessage(text=text)]
1269
+ )
1270
+ await line_bot_api.multicast(multicast_request)
1271
+
1272
+ logging.info(
1273
+ f"[Line Bot Multicast] Successfully sent multicast to {len(user_ids)} users"
1274
+ )
1275
+
1276
+ return {"status": "ok", "sent_to": len(user_ids)}
1277
+
1278
+ except HTTPException:
1279
+ raise
1280
+ except Exception as e:
1281
+ logging.error(f"[Line Bot Multicast] Unexpected error: {e}")
1282
+ traceback.print_exc()
1283
+ raise HTTPException(status_code=500, detail="Internal server error")
1284
+
1285
+
1286
+ def _extract_ai_message_outputs(raw_output, langgraph_node: str) -> str:
1287
+ """根據 langgraph_node 從 raw_output 中提取 AI 訊息輸出"""
1288
+ ai_message_outputs = ""
1289
+
1290
+ try:
1291
+ if langgraph_node == "extract":
1292
+ if hasattr(raw_output, "tool_calls"):
1293
+ for tool_call in raw_output.tool_calls:
1294
+ if tool_call.get("name") == "RequirementPromptInstructions":
1295
+ args = tool_call.get("args", {})
1296
+ ai_message_outputs = str(args)
1297
+ break
1298
+ elif langgraph_node in ["search_node", "normal_chat_node"]:
1299
+ if hasattr(raw_output, "content"):
1300
+ ai_message_outputs = str(raw_output.content)
1301
+ elif langgraph_node == "related_node":
1302
+ if (
1303
+ hasattr(raw_output, "additional_kwargs")
1304
+ and "tool_calls" in raw_output.additional_kwargs
1305
+ ):
1306
+ tool_calls = raw_output.additional_kwargs["tool_calls"]
1307
+ for tool_call in tool_calls:
1308
+ if (
1309
+ tool_call.get("function", {}).get("name")
1310
+ == "RelatedQuestionsInstructions"
1311
+ ):
1312
+ arguments = json.loads(tool_call["function"]["arguments"])
1313
+ related_questions = arguments.get("related_questions", [])
1314
+ ai_message_outputs = "; ".join(related_questions)
1315
+ break
1316
+ except Exception as e:
1317
+ logging.error(
1318
+ f"[Line Bot API] Failed to extract AI message outputs for {langgraph_node}: {e}"
1319
+ )
1320
+
1321
+ return ai_message_outputs
1322
+
1323
+
1324
+ def _extract_input_messages(raw_input) -> list[str]:
1325
+ """從 raw_input 中提取所有輸入訊息"""
1326
+ inputs = []
1327
+
1328
+ try:
1329
+ # 檢查 raw_input 是否為字典且包含 messages
1330
+ if isinstance(raw_input, dict) and "messages" in raw_input:
1331
+ messages = raw_input["messages"]
1332
+ ai_messages = []
1333
+ human_messages = []
1334
+ system_messages = []
1335
+
1336
+ for msg in messages:
1337
+ for nested_msg in msg:
1338
+ if hasattr(nested_msg, "__class__"):
1339
+ msg_type = nested_msg.__class__.__name__
1340
+ msg_content = str(getattr(nested_msg, "content", ""))
1341
+
1342
+ if msg_type == "AIMessage":
1343
+ ai_messages.append(msg_content)
1344
+ elif msg_type == "HumanMessage":
1345
+ human_messages.append(msg_content)
1346
+ elif msg_type == "SystemMessage":
1347
+ system_messages.append(msg_content)
1348
+
1349
+ inputs = ai_messages + human_messages + system_messages
1350
+ else:
1351
+ # 如果 raw_input 不是預期的格式,嘗試轉換為字串
1352
+ inputs = [str(raw_input)] if raw_input is not None else []
1353
+ except Exception as e:
1354
+ logging.error(f"[Line Bot API] Failed to extract input messages: {e}")
1355
+ inputs = [str(raw_input)] if raw_input is not None else []
1356
+
1357
+ return inputs