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