botrun-flow-lang 5.11.11__py3-none-any.whl → 5.12.261__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.
@@ -286,13 +286,40 @@ async def get_hatches(
286
286
  user_id: str,
287
287
  offset: int = Query(0, ge=0),
288
288
  limit: int = Query(20, ge=1, le=100),
289
+ sort_by: str = Query("updated_at", description="Field to sort by (name, updated_at)"),
290
+ order: str = Query("desc", regex="^(asc|desc)$", description="Sort order: asc or desc"),
289
291
  current_user: CurrentUser = Depends(verify_jwt_token),
290
292
  hatch_store=Depends(get_hatch_store),
291
293
  ):
294
+ """Get hatches for a user with sorting options.
295
+
296
+ Args:
297
+ user_id: User ID to get hatches for
298
+ offset: Pagination offset
299
+ limit: Maximum number of results (1-100)
300
+ sort_by: Field to sort by - only 'name' or 'updated_at' are supported (default: updated_at)
301
+ order: Sort order - 'asc' or 'desc' (default: desc for newest first)
302
+
303
+ Returns:
304
+ List of hatches sorted by the specified field
305
+
306
+ Raises:
307
+ HTTPException: 400 if sort_by field is not supported
308
+ """
292
309
  # Verify user permission to access hatches for the specified user_id
293
310
  verify_user_permission(current_user, user_id)
294
311
 
295
- hatches, error = await hatch_store.get_hatches(user_id, offset, limit)
312
+ # Validate sort_by field - only allow fields with Firestore indexes
313
+ allowed_sort_fields = ["name", "updated_at"]
314
+ if sort_by not in allowed_sort_fields:
315
+ raise HTTPException(
316
+ status_code=400,
317
+ detail=f"Invalid sort_by field '{sort_by}'. Allowed fields: {', '.join(allowed_sort_fields)}",
318
+ )
319
+
320
+ hatches, error = await hatch_store.get_hatches(
321
+ user_id, offset, limit, sort_by, order
322
+ )
296
323
  if error:
297
324
  raise HTTPException(status_code=500, detail=error)
298
325
  return hatches
@@ -100,6 +100,11 @@ BOTRUN_FRONT_URL = os.getenv("BOTRUN_FRONT_URL", None)
100
100
  SUBSIDY_API_TOKEN = os.getenv("SUBSIDY_API_TOKEN", None)
101
101
  SUBSIDY_API_URL = os.getenv("SUBSIDY_API_URL", "https://p271-subsidy-ie7vwovclq-de.a.run.app/v1/generateContent")
102
102
 
103
+ # BigQuery Token Logging API 相關環境變數
104
+ BIGQUERY_TOKEN_LOG_API_URL = os.getenv("BIGQUERY_TOKEN_LOG_API_URL", "http://localhost:8002/api/v1/logs/text")
105
+ BIGQUERY_TOKEN_LOG_ENABLED = os.getenv("BIGQUERY_TOKEN_LOG_ENABLED", "true").lower() == "true"
106
+ SUBSIDY_LINE_BOT_MODEL_NAME = os.getenv("SUBSIDY_LINE_BOT_MODEL_NAME", "gemini-2.0-flash-thinking-exp")
107
+
103
108
  # 全局變數
104
109
  # 用於追蹤正在處理訊息的使用者,避免同一使用者同時發送多條訊息造成處理衝突
105
110
  _processing_users = set()
@@ -202,6 +207,111 @@ async def log_to_bigquery(
202
207
  )
203
208
 
204
209
 
210
+ async def log_tokens_to_bigquery(
211
+ user_id: str,
212
+ display_name: str,
213
+ log_content: str,
214
+ model: str,
215
+ input_tokens: int | None,
216
+ output_tokens: int | None,
217
+ total_tokens: int | None,
218
+ request: Request,
219
+ session_id: str = "",
220
+ ) -> None:
221
+ """
222
+ 記錄 token 使用量到 BigQuery logging API
223
+
224
+ Args:
225
+ user_id: LINE 使用者 ID
226
+ display_name: 使用者顯示名稱
227
+ log_content: 使用者輸入訊息
228
+ model: 使用的 AI 模型
229
+ input_tokens: 輸入 token 數量
230
+ output_tokens: 輸出 token 數量
231
+ total_tokens: 總 token 數量
232
+ request: FastAPI Request 物件
233
+ session_id: Session ID (可選,預設使用 user_id)
234
+ """
235
+ # 檢查功能是否啟用
236
+ if not BIGQUERY_TOKEN_LOG_ENABLED:
237
+ logging.debug("[Token Logger] BigQuery token logging is disabled")
238
+ return
239
+
240
+ start_time = time.time()
241
+
242
+ try:
243
+ tz = pytz.timezone("Asia/Taipei")
244
+ current_time = datetime.now(tz)
245
+
246
+ # 組裝 payload
247
+ payload = {
248
+ "action_details": log_content,
249
+ "action_type": "call_subsidy_api",
250
+ "botrun": "subsidy_line_bot",
251
+ "dataset_name": os.getenv("BOTRUN_LOG_DATASET_NAME", "subsidy_line_bot"),
252
+ "department": os.getenv("BOTRUN_LOG_DEPARTMENT", "subsidy_line_bot"),
253
+ "developer": "",
254
+ "domain_name": "subsidy_line_bot",
255
+ "input_tokens": input_tokens,
256
+ "model": model,
257
+ "output_tokens": output_tokens,
258
+ "resource_id": json.dumps({
259
+ "user_id": user_id,
260
+ "timestamp": current_time.strftime("%Y-%m-%dT%H:%M:%SZ")
261
+ }),
262
+ "session_id": session_id or user_id,
263
+ "total_tokens": total_tokens,
264
+ "user_agent": request.headers.get("user-agent", "Line Platform"),
265
+ "user_name": display_name
266
+ }
267
+
268
+ logging.info(
269
+ f"[Token Logger] Logging tokens for user {display_name} ({user_id}): "
270
+ f"input={input_tokens}, output={output_tokens}, total={total_tokens}"
271
+ )
272
+
273
+ # 使用 aiohttp 非同步呼叫 API
274
+ timeout = aiohttp.ClientTimeout(total=10) # 10 秒超時
275
+ async with aiohttp.ClientSession(timeout=timeout) as session:
276
+ async with session.post(
277
+ BIGQUERY_TOKEN_LOG_API_URL,
278
+ json=payload,
279
+ headers={"Content-Type": "application/json"}
280
+ ) as response:
281
+ response_text = await response.text()
282
+
283
+ if response.status == 200:
284
+ elapsed_time = time.time() - start_time
285
+ logging.info(
286
+ f"[Token Logger] Successfully logged tokens to BigQuery for user "
287
+ f"{display_name} ({user_id}), elapsed time: {elapsed_time:.3f}s"
288
+ )
289
+ else:
290
+ logging.error(
291
+ f"[Token Logger] Failed to log tokens, API returned status {response.status}: {response_text}"
292
+ )
293
+
294
+ except asyncio.TimeoutError:
295
+ elapsed_time = time.time() - start_time
296
+ logging.error(
297
+ f"[Token Logger] Timeout while logging tokens for user {display_name} ({user_id}), "
298
+ f"elapsed time: {elapsed_time:.3f}s"
299
+ )
300
+ except aiohttp.ClientError as e:
301
+ elapsed_time = time.time() - start_time
302
+ logging.error(
303
+ f"[Token Logger] Network error while logging tokens for user {display_name} ({user_id}): {e}, "
304
+ f"elapsed time: {elapsed_time:.3f}s"
305
+ )
306
+ except Exception as e:
307
+ elapsed_time = time.time() - start_time
308
+ logging.error(
309
+ f"[Token Logger] Unexpected error while logging tokens for user {display_name} ({user_id}): {e}, "
310
+ f"elapsed time: {elapsed_time:.3f}s"
311
+ )
312
+ traceback.print_exc()
313
+
314
+
205
315
  def get_prompt_from_google_doc(tag_name: str, fallback_prompt: str = ""):
206
316
  """
207
317
  從 Google 文件中提取指定標籤的內容
@@ -612,10 +722,11 @@ async def subsidy_webhook(request: Request):
612
722
  )
613
723
  responses.append(response)
614
724
 
725
+ # NOTE: 按讚反讚功能已暫時停用(2025-12-03),日後需要可以取消註解以下程式碼
615
726
  # 處理使用者藉由按讚反讚按鈕反饋的postback事件
616
- elif isinstance(event, PostbackEvent):
617
- await handle_feedback(event, line_bot_api)
618
- responses.append("feedback_handled")
727
+ # elif isinstance(event, PostbackEvent):
728
+ # await handle_feedback(event, line_bot_api)
729
+ # responses.append("feedback_handled")
619
730
 
620
731
  return {"responses": responses}
621
732
 
@@ -736,13 +847,37 @@ async def handle_message(
736
847
  _processing_users.add(user_id)
737
848
 
738
849
  try:
739
- reply_text, related_questions = await get_reply_text(
850
+ reply_text, related_questions, usage_metadata = await get_reply_text(
740
851
  user_message, user_id, display_name, request
741
852
  )
742
853
  logging.info(
743
854
  f"[Line Bot Webhook: handle_message] Total response length: {len(reply_text)}"
744
855
  )
745
856
 
857
+ # 記錄 token 使用量到 BigQuery (非阻塞式)
858
+ if usage_metadata:
859
+ # 把 user_message 跟 reply_text 合併成 json 格式紀錄
860
+ log_content = json.dumps(
861
+ {
862
+ "user_message": user_message,
863
+ "reply_text": reply_text
864
+ },
865
+ ensure_ascii=False
866
+ )
867
+ asyncio.create_task(
868
+ log_tokens_to_bigquery(
869
+ user_id=user_id,
870
+ display_name=display_name,
871
+ log_content=log_content,
872
+ model=usage_metadata.get("model", SUBSIDY_LINE_BOT_MODEL_NAME),
873
+ input_tokens=usage_metadata.get("promptTokenCount", None),
874
+ output_tokens=usage_metadata.get("candidatesTokenCount", None),
875
+ total_tokens=usage_metadata.get("totalTokenCount", None),
876
+ request=request,
877
+ session_id=user_id,
878
+ )
879
+ )
880
+
746
881
  # 將長訊息分段,每段不超過 LINE_MAX_MESSAGE_LENGTH
747
882
  message_chunks = []
748
883
  remaining_text = reply_text
@@ -829,34 +964,40 @@ async def handle_message(
829
964
  )
830
965
  )
831
966
 
967
+ # NOTE: 按讚反讚功能已暫時停用(2025-12-03),日後需要可以取消註解以下程式碼
832
968
  # 以 Quick Reply 作為按讚反讚按鈕
833
- quick_reply = QuickReply(
834
- items=[
835
- QuickReplyItem(
836
- action=PostbackAction(
837
- label="津好康,真是棒👍🏻",
838
- data="實用",
839
- display_text="津好康,真是棒👍🏻",
840
- )
841
- ),
842
- QuickReplyItem(
843
- action=PostbackAction(
844
- label="津可惜,不太實用😖",
845
- data="不實用",
846
- display_text="津可惜,不太實用😖",
847
- )
848
- ),
849
- ]
850
- )
969
+ # quick_reply = QuickReply(
970
+ # items=[
971
+ # QuickReplyItem(
972
+ # action=PostbackAction(
973
+ # label="津好康,真是棒👍🏻",
974
+ # data="實用",
975
+ # display_text="津好康,真是棒👍🏻",
976
+ # )
977
+ # ),
978
+ # QuickReplyItem(
979
+ # action=PostbackAction(
980
+ # label="津可惜,不太實用😖",
981
+ # data="不實用",
982
+ # display_text="津可惜,不太實用😖",
983
+ # )
984
+ # ),
985
+ # ]
986
+ # )
851
987
 
852
988
  if question_bubble:
853
989
  messages.append(FlexMessage(alt_text="相關問題", contents=question_bubble))
854
990
 
855
- messages[-1].quick_reply = quick_reply
856
-
991
+ # messages[-1].quick_reply = quick_reply
992
+ logging.info(
993
+ f"[Line Bot Webhook: handle_message] start reply_message"
994
+ )
857
995
  await line_bot_api.reply_message(
858
996
  ReplyMessageRequest(reply_token=event.reply_token, messages=messages)
859
997
  )
998
+ logging.info(
999
+ f"[Line Bot Webhook: handle_message] end reply_message"
1000
+ )
860
1001
  except Exception as e:
861
1002
  traceback.print_exc()
862
1003
  logging.error(
@@ -934,7 +1075,7 @@ async def get_reply_text(
934
1075
  user_id: str,
935
1076
  display_name: str,
936
1077
  request: Request,
937
- ) -> tuple[str, list]:
1078
+ ) -> tuple[str, list, dict]:
938
1079
  """
939
1080
  使用外部 API 處理使用者訊息並回傳回覆內容
940
1081
 
@@ -945,20 +1086,30 @@ async def get_reply_text(
945
1086
  request (Request): FastAPI request 物件,用於記錄到 BigQuery
946
1087
 
947
1088
  Returns:
948
- tuple[str, list]: 包含回覆訊息和相關問題的元組
1089
+ tuple[str, list, dict]: 包含回覆訊息、相關問題和 token 使用量的元組
949
1090
  """
950
1091
  start_time = time.time()
951
1092
 
952
1093
  try:
953
1094
  # 取得系統指令
954
- system_instruction = get_subsidy_api_system_prompt()
1095
+ # 暫時不需要,因為現在是直接呼叫 cbh 的 api, prompt 在它裡面
1096
+ # system_instruction = get_subsidy_api_system_prompt()
955
1097
 
956
1098
  # 調用外部 API
957
1099
  api_response = await call_subsidy_api(
958
1100
  user_message=line_user_message,
959
1101
  user_id=user_id,
960
1102
  display_name=display_name,
961
- system_instruction=system_instruction
1103
+ # system_instruction=system_instruction
1104
+ )
1105
+
1106
+ # 提取 token 使用量資訊
1107
+ usage_metadata = api_response.get("usageMetadata", {})
1108
+ logging.info(
1109
+ f"[Line Bot Webhook: get_reply_text] Token usage: "
1110
+ f"input={usage_metadata.get('promptTokenCount', 0)}, "
1111
+ f"output={usage_metadata.get('candidatesTokenCount', 0)}, "
1112
+ f"total={usage_metadata.get('totalTokenCount', 0)}"
962
1113
  )
963
1114
 
964
1115
  # 從 API 回應中提取文字內容
@@ -1003,7 +1154,7 @@ async def get_reply_text(
1003
1154
  f"[Line Bot Webhook: get_reply_text] total took {time.time() - start_time:.3f}s"
1004
1155
  )
1005
1156
 
1006
- return full_response, related_questions
1157
+ return full_response, related_questions, usage_metadata
1007
1158
 
1008
1159
  except Exception as e:
1009
1160
  import traceback
@@ -1012,7 +1163,7 @@ async def get_reply_text(
1012
1163
 
1013
1164
  # 返回錯誤訊息
1014
1165
  error_message = "抱歉,處理您的訊息時遇到問題,請稍後再試。"
1015
- return error_message, []
1166
+ return error_message, [], {}
1016
1167
 
1017
1168
 
1018
1169
  async def handle_feedback(
@@ -71,15 +71,19 @@ async def langgraph_runner(
71
71
  # 設定新的 recursion_limit 為 (multiplier + 1) * MAX_RECURSION_LIMIT
72
72
  config["recursion_limit"] = (multiplier + 1) * MAX_RECURSION_LIMIT
73
73
 
74
- async for event in graph.astream_events(
75
- invoke_state,
76
- config,
77
- version="v2",
78
- ):
79
- # state = await graph.aget_state(config)
80
- # print(state.config)
81
-
82
- yield event
74
+ try:
75
+ async for event in graph.astream_events(
76
+ invoke_state,
77
+ config,
78
+ version="v2",
79
+ ):
80
+ yield event
81
+ except Exception as e:
82
+ # 捕獲 SSE 流讀取錯誤(如 httpcore.ReadError)
83
+ import logging
84
+ logging.error(f"Error reading SSE stream: {e}", exc_info=True)
85
+ # 產生錯誤 event 讓調用者知道
86
+ yield {"error": f"SSE stream error: {str(e)}"}
83
87
 
84
88
 
85
89
  # graph 是 CompiledStateGraph,不傳入型別的原因是,loading import 需要 0.5秒
@@ -142,11 +142,42 @@ def get_react_agent_model_name(model_name: str = ""):
142
142
 
143
143
  ANTHROPIC_MAX_TOKENS = 64000
144
144
  GEMINI_MAX_TOKENS = 32000
145
+ TAIDE_MAX_TOKENS = 8192
145
146
 
146
147
 
147
148
  def get_react_agent_model(model_name: str = ""):
148
149
  final_model_name = get_react_agent_model_name(model_name).strip()
149
150
 
151
+ # 處理 taide/ 前綴的模型
152
+ if final_model_name.startswith("taide/"):
153
+ taide_api_key = os.getenv("TAIDE_API_KEY", "")
154
+ taide_base_url = os.getenv("TAIDE_BASE_URL", "")
155
+
156
+ if not taide_api_key or not taide_base_url:
157
+ raise ValueError(
158
+ f"Model name starts with 'taide/' but TAIDE_API_KEY or TAIDE_BASE_URL not set. "
159
+ f"Both environment variables are required for: {final_model_name}"
160
+ )
161
+
162
+ # 取得 taide/ 後面的模型名稱
163
+ taide_model_name = final_model_name[len("taide/"):]
164
+
165
+ if not taide_model_name:
166
+ raise ValueError(
167
+ f"Invalid taide model format: {final_model_name}. "
168
+ "Expected format: taide/<model_name>"
169
+ )
170
+
171
+ model = ChatOpenAI(
172
+ openai_api_key=taide_api_key,
173
+ openai_api_base=taide_base_url,
174
+ model_name=taide_model_name,
175
+ temperature=0,
176
+ max_tokens=TAIDE_MAX_TOKENS,
177
+ )
178
+ logger.info(f"model ChatOpenAI (TAIDE) {taide_model_name} @ {taide_base_url}")
179
+ return model
180
+
150
181
  # 處理 vertexai/ 前綴的模型
151
182
  if final_model_name.startswith("vertex-ai/"):
152
183
  vertex_project = os.getenv("VERTEX_AI_LANGCHAIN_PROJECT", "")