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