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.
Files changed (84) hide show
  1. botrun_flow_lang/api/auth_api.py +39 -39
  2. botrun_flow_lang/api/auth_utils.py +183 -183
  3. botrun_flow_lang/api/botrun_back_api.py +65 -65
  4. botrun_flow_lang/api/flow_api.py +3 -3
  5. botrun_flow_lang/api/hatch_api.py +481 -481
  6. botrun_flow_lang/api/langgraph_api.py +796 -796
  7. botrun_flow_lang/api/line_bot_api.py +1357 -1357
  8. botrun_flow_lang/api/model_api.py +300 -300
  9. botrun_flow_lang/api/rate_limit_api.py +32 -32
  10. botrun_flow_lang/api/routes.py +79 -79
  11. botrun_flow_lang/api/search_api.py +53 -53
  12. botrun_flow_lang/api/storage_api.py +316 -316
  13. botrun_flow_lang/api/subsidy_api.py +290 -290
  14. botrun_flow_lang/api/subsidy_api_system_prompt.txt +109 -109
  15. botrun_flow_lang/api/user_setting_api.py +70 -70
  16. botrun_flow_lang/api/version_api.py +31 -31
  17. botrun_flow_lang/api/youtube_api.py +26 -26
  18. botrun_flow_lang/constants.py +13 -13
  19. botrun_flow_lang/langgraph_agents/agents/agent_runner.py +174 -174
  20. botrun_flow_lang/langgraph_agents/agents/agent_tools/step_planner.py +77 -77
  21. botrun_flow_lang/langgraph_agents/agents/checkpointer/firestore_checkpointer.py +666 -666
  22. botrun_flow_lang/langgraph_agents/agents/gov_researcher/GOV_RESEARCHER_PRD.md +192 -192
  23. botrun_flow_lang/langgraph_agents/agents/gov_researcher/gov_researcher_2_graph.py +1002 -1002
  24. botrun_flow_lang/langgraph_agents/agents/gov_researcher/gov_researcher_graph.py +822 -822
  25. botrun_flow_lang/langgraph_agents/agents/langgraph_react_agent.py +591 -548
  26. botrun_flow_lang/langgraph_agents/agents/search_agent_graph.py +864 -864
  27. botrun_flow_lang/langgraph_agents/agents/tools/__init__.py +4 -4
  28. botrun_flow_lang/langgraph_agents/agents/tools/gemini_code_execution.py +376 -376
  29. botrun_flow_lang/langgraph_agents/agents/util/gemini_grounding.py +66 -66
  30. botrun_flow_lang/langgraph_agents/agents/util/html_util.py +316 -316
  31. botrun_flow_lang/langgraph_agents/agents/util/img_util.py +294 -294
  32. botrun_flow_lang/langgraph_agents/agents/util/local_files.py +345 -345
  33. botrun_flow_lang/langgraph_agents/agents/util/mermaid_util.py +86 -86
  34. botrun_flow_lang/langgraph_agents/agents/util/model_utils.py +143 -143
  35. botrun_flow_lang/langgraph_agents/agents/util/pdf_analyzer.py +160 -160
  36. botrun_flow_lang/langgraph_agents/agents/util/perplexity_search.py +464 -464
  37. botrun_flow_lang/langgraph_agents/agents/util/plotly_util.py +59 -59
  38. botrun_flow_lang/langgraph_agents/agents/util/tavily_search.py +199 -199
  39. botrun_flow_lang/langgraph_agents/agents/util/youtube_util.py +90 -90
  40. botrun_flow_lang/langgraph_agents/cache/langgraph_botrun_cache.py +197 -197
  41. botrun_flow_lang/llm_agent/llm_agent.py +19 -19
  42. botrun_flow_lang/llm_agent/llm_agent_util.py +83 -83
  43. botrun_flow_lang/log/.gitignore +2 -2
  44. botrun_flow_lang/main.py +61 -61
  45. botrun_flow_lang/main_fast.py +51 -51
  46. botrun_flow_lang/mcp_server/__init__.py +10 -10
  47. botrun_flow_lang/mcp_server/default_mcp.py +711 -711
  48. botrun_flow_lang/models/nodes/utils.py +205 -205
  49. botrun_flow_lang/models/token_usage.py +34 -34
  50. botrun_flow_lang/requirements.txt +21 -21
  51. botrun_flow_lang/services/base/firestore_base.py +30 -30
  52. botrun_flow_lang/services/hatch/hatch_factory.py +11 -11
  53. botrun_flow_lang/services/hatch/hatch_fs_store.py +372 -372
  54. botrun_flow_lang/services/storage/storage_cs_store.py +202 -202
  55. botrun_flow_lang/services/storage/storage_factory.py +12 -12
  56. botrun_flow_lang/services/storage/storage_store.py +65 -65
  57. botrun_flow_lang/services/user_setting/user_setting_factory.py +9 -9
  58. botrun_flow_lang/services/user_setting/user_setting_fs_store.py +66 -66
  59. botrun_flow_lang/static/docs/tools/index.html +926 -926
  60. botrun_flow_lang/tests/api_functional_tests.py +1525 -1525
  61. botrun_flow_lang/tests/api_stress_test.py +357 -357
  62. botrun_flow_lang/tests/shared_hatch_tests.py +333 -333
  63. botrun_flow_lang/tests/test_botrun_app.py +46 -46
  64. botrun_flow_lang/tests/test_html_util.py +31 -31
  65. botrun_flow_lang/tests/test_img_analyzer.py +190 -190
  66. botrun_flow_lang/tests/test_img_util.py +39 -39
  67. botrun_flow_lang/tests/test_local_files.py +114 -114
  68. botrun_flow_lang/tests/test_mermaid_util.py +103 -103
  69. botrun_flow_lang/tests/test_pdf_analyzer.py +104 -104
  70. botrun_flow_lang/tests/test_plotly_util.py +151 -151
  71. botrun_flow_lang/tests/test_run_workflow_engine.py +65 -65
  72. botrun_flow_lang/tools/generate_docs.py +133 -133
  73. botrun_flow_lang/tools/templates/tools.html +153 -153
  74. botrun_flow_lang/utils/__init__.py +7 -7
  75. botrun_flow_lang/utils/botrun_logger.py +344 -344
  76. botrun_flow_lang/utils/clients/rate_limit_client.py +209 -209
  77. botrun_flow_lang/utils/clients/token_verify_client.py +153 -153
  78. botrun_flow_lang/utils/google_drive_utils.py +654 -654
  79. botrun_flow_lang/utils/langchain_utils.py +324 -324
  80. botrun_flow_lang/utils/yaml_utils.py +9 -9
  81. {botrun_flow_lang-5.10.82.dist-info → botrun_flow_lang-5.10.83.dist-info}/METADATA +3 -2
  82. botrun_flow_lang-5.10.83.dist-info/RECORD +99 -0
  83. botrun_flow_lang-5.10.82.dist-info/RECORD +0 -99
  84. {botrun_flow_lang-5.10.82.dist-info → botrun_flow_lang-5.10.83.dist-info}/WHEEL +0 -0
@@ -1,796 +1,796 @@
1
- import logging
2
- import uuid
3
- import json
4
- import random
5
- import time
6
- import re
7
-
8
- from fastapi import APIRouter, HTTPException
9
-
10
- from pydantic import BaseModel
11
-
12
- from typing import Dict, Any, List, Optional
13
-
14
- from fastapi.responses import StreamingResponse
15
-
16
- from botrun_flow_lang.constants import ERR_GRAPH_RECURSION_ERROR, LANG_EN, LANG_ZH_TW
17
-
18
- from botrun_flow_lang.langgraph_agents.agents.agent_runner import (
19
- agent_runner,
20
- langgraph_runner,
21
- )
22
-
23
- from botrun_flow_lang.langgraph_agents.agents.gov_researcher.gov_researcher_graph import (
24
- GovResearcherGraph,
25
- get_content_for_gov_researcher,
26
- )
27
- from botrun_flow_lang.langgraph_agents.agents.gov_researcher.gov_researcher_2_graph import (
28
- TAIWAN_SUBSIDY_SUPERVISOR_PROMPT,
29
- create_taiwan_subsidy_agent_graph,
30
- taiwan_subsidy_agent_graph,
31
- )
32
- from botrun_flow_lang.langgraph_agents.agents.langgraph_react_agent import (
33
- create_react_agent_graph,
34
- get_react_agent_model_name,
35
- )
36
-
37
- from botrun_flow_lang.langgraph_agents.cache.langgraph_botrun_cache import (
38
- get_botrun_cache,
39
- )
40
-
41
- from botrun_flow_lang.models.token_usage import TokenUsage
42
-
43
- from botrun_flow_lang.utils.botrun_logger import (
44
- get_session_botrun_logger,
45
- default_logger,
46
- )
47
-
48
- # 放到要用的時候才 init,不然loading 會花時間
49
- # 因為要讓 langgraph 在本地端執行,所以這一段又搬回到外面了
50
- from langgraph.errors import GraphRecursionError
51
- import anthropic # Keep relevant imports if needed for error handling here
52
-
53
- # ==========
54
-
55
- from botrun_flow_lang.langgraph_agents.agents.search_agent_graph import (
56
- SearchAgentGraph,
57
- # graph as search_agent_graph,
58
- )
59
-
60
- from botrun_flow_lang.utils.langchain_utils import (
61
- extract_token_usage_from_state,
62
- langgraph_event_to_json,
63
- litellm_msgs_to_langchain_msgs,
64
- )
65
-
66
-
67
- router = APIRouter(prefix="/langgraph")
68
-
69
-
70
- class LangGraphRequest(BaseModel):
71
- graph_name: str
72
- # todo LangGraph 應該要傳 thread_id,但是因為現在是 cloud run 的架構,所以 thread_id 不一定會讀的到 (auto scale)
73
- thread_id: Optional[str] = None
74
- user_input: Optional[str] = None
75
- messages: List[Dict[str, Any]] = []
76
- config: Optional[Dict[str, Any]] = None
77
- stream: bool = False
78
- # LangGraph 是否需要從 checkpoint 恢復
79
- need_resume: bool = False
80
- session_id: Optional[str] = None
81
-
82
-
83
- class LangGraphResponse(BaseModel):
84
- """
85
- @param content: 這個是給評測用來評估結果用的
86
- @param state: 這個是graph的 final state,如果需要額外資訊可以使用
87
- @param token_usage: Token usage information for the entire graph execution
88
- """
89
-
90
- id: str
91
- object: str = "chat.completion"
92
- created: int
93
- model: str
94
- content: Optional[str] = None
95
- state: Optional[Dict[str, Any]] = None
96
- token_usage: Optional[TokenUsage] = None
97
-
98
-
99
- class SupportedGraphsResponse(BaseModel):
100
- """Response model for listing supported graphs"""
101
-
102
- graphs: List[str]
103
-
104
-
105
- class GraphSchemaRequest(BaseModel):
106
- """Request model for getting graph schema"""
107
-
108
- graph_name: str
109
-
110
-
111
- PERPLEXITY_SEARCH_AGENT = "perplexity_search_agent"
112
- CUSTOM_WEB_RESEARCH_AGENT = "custom_web_research_agent"
113
- LANGGRAPH_REACT_AGENT = "langgraph_react_agent"
114
- DEEP_RESEARCH_AGENT = "deep_research_agent"
115
- # GOV_RESEARCHER_AGENT = "gov_researcher_agent"
116
- GOV_SUBSIDY_AGENT = "gov_subsidy_agent"
117
-
118
-
119
- SUPPORTED_GRAPH_NAMES = [
120
- # PERPLEXITY_SEARCH_AGENT,
121
- LANGGRAPH_REACT_AGENT,
122
- GOV_SUBSIDY_AGENT,
123
- # GOV_RESEARCHER_AGENT,
124
- ]
125
- SUPPORTED_GRAPH = {
126
- GOV_SUBSIDY_AGENT: taiwan_subsidy_agent_graph,
127
- }
128
-
129
-
130
- def contains_chinese_chars(text: str) -> bool:
131
- """Check if the given text contains any Chinese characters."""
132
- if not text:
133
- return False
134
- # This pattern matches Chinese characters (both simplified and traditional)
135
- pattern = re.compile(
136
- r"[\u4e00-\u9fff\u3400-\u4dbf\U00020000-\U0002a6df\U0002a700-\U0002ebef]"
137
- )
138
- return bool(pattern.search(text))
139
-
140
-
141
- async def get_cached_or_create_react_graph(
142
- botrun_id: Optional[str], # Key parameter - can be None/empty
143
- config: Optional[Dict[str, Any]] = None,
144
- messages: Optional[List[Dict]] = None,
145
- user_input: Optional[str] = None,
146
- logger: logging.Logger = default_logger,
147
- ) -> Any:
148
- """
149
- Get cached graph for LANGGRAPH_REACT_AGENT or create new one.
150
- Handles botrun_id-based caching with parameter validation.
151
-
152
- Args:
153
- botrun_id: The botrun ID for cache lookup. If None/empty, skips caching.
154
- config: Configuration dictionary
155
- messages: List of message dictionaries
156
- user_input: User input string
157
- logger: Logger instance
158
-
159
- Returns:
160
- Graph instance (cached or newly created)
161
- """
162
- config = config or {}
163
-
164
- # Extract parameters for hash calculation (moved from get_graph)
165
- system_prompt = config.get("system_prompt", "")
166
- if messages:
167
- for message in messages:
168
- if message.get("role") == "system":
169
- system_prompt = message.get("content", "")
170
-
171
- botrun_flow_lang_url = config.get("botrun_flow_lang_url", "")
172
- user_id = config.get("user_id", "")
173
- model_name = config.get("model_name", "")
174
-
175
- # Determine language (moved from get_graph)
176
- has_chinese = contains_chinese_chars(system_prompt)
177
- if not has_chinese and user_input:
178
- has_chinese = contains_chinese_chars(user_input)
179
- lang = LANG_ZH_TW if has_chinese else LANG_EN
180
-
181
- mcp_config = config.get("mcp_config")
182
-
183
- # CRITICAL: Check if botrun_id is provided and not empty
184
- if not botrun_id:
185
- # If botrun_id is None or empty, skip caching entirely
186
- logger.info("No botrun_id provided, creating new graph without caching")
187
- graph = await create_react_agent_graph(
188
- system_prompt=system_prompt,
189
- botrun_flow_lang_url=botrun_flow_lang_url,
190
- user_id=user_id,
191
- model_name=model_name,
192
- lang=lang,
193
- mcp_config=mcp_config,
194
- )
195
- return graph
196
-
197
- # If botrun_id is provided, use caching logic
198
- cache = get_botrun_cache()
199
- params_hash = cache.get_params_hash(
200
- system_prompt, botrun_flow_lang_url, user_id, model_name, lang, mcp_config
201
- )
202
-
203
- # Try to get cached graph
204
- cached_graph = cache.get_cached_graph(botrun_id, params_hash)
205
- if cached_graph:
206
- logger.info(f"Using cached graph for botrun_id: {botrun_id}")
207
- return cached_graph
208
-
209
- # Create new graph (same logic as in get_graph)
210
- logger.info(f"Creating new graph for botrun_id: {botrun_id}")
211
- graph = await create_react_agent_graph(
212
- system_prompt=system_prompt,
213
- botrun_flow_lang_url=botrun_flow_lang_url,
214
- user_id=user_id,
215
- model_name=model_name,
216
- lang=lang,
217
- mcp_config=mcp_config,
218
- )
219
-
220
- # Cache the new graph
221
- cache.cache_graph(botrun_id, params_hash, graph)
222
-
223
- return graph
224
-
225
-
226
- async def get_graph(
227
- graph_name: str,
228
- config: Optional[Dict[str, Any]] = None,
229
- stream: bool = False,
230
- messages: Optional[List[Dict]] = [],
231
- user_input: Optional[str] = None,
232
- ):
233
- if (
234
- graph_name not in SUPPORTED_GRAPH_NAMES
235
- and graph_name not in SUPPORTED_GRAPH.keys()
236
- ):
237
- raise ValueError(f"Unsupported graph from get_graph: {graph_name}")
238
- if graph_name == PERPLEXITY_SEARCH_AGENT:
239
- graph = SearchAgentGraph().graph
240
- graph_config = {
241
- "search_prompt": config.get("search_prompt", ""),
242
- "model_name": config.get("model_name", "sonar-reasoning-pro"),
243
- "related_prompt": config.get("related_question_prompt", ""),
244
- "search_vendor": config.get("search_vendor", "perplexity"),
245
- "domain_filter": config.get("domain_filter", []),
246
- "user_prompt_prefix": config.get("user_prompt_prefix", ""),
247
- "stream": stream,
248
- }
249
- elif graph_name == GOV_SUBSIDY_AGENT:
250
- graph = create_taiwan_subsidy_agent_graph(
251
- config.get("prompt_template", TAIWAN_SUBSIDY_SUPERVISOR_PROMPT)
252
- )
253
- graph_config = {
254
- "prompt_template": config.get("prompt_template", ""),
255
- "legal_extraction_prompt": config.get("legal_extraction_prompt", ""),
256
- "faq_extraction_prompt": config.get("faq_extraction_prompt", ""),
257
- "calculation_analysis_prompt": config.get(
258
- "calculation_analysis_prompt", ""
259
- ),
260
- }
261
- else:
262
- raise ValueError(f"Unsupported graph type: {graph_name}")
263
- return graph, graph_config
264
-
265
-
266
- def get_init_state(
267
- graph_name: str,
268
- user_input: str,
269
- config: Optional[Dict[str, Any]] = None,
270
- messages: Optional[List[Dict]] = [],
271
- enable_prompt_caching: bool = False,
272
- ):
273
- if graph_name == PERPLEXITY_SEARCH_AGENT:
274
- if len(messages) > 0:
275
- return {"messages": litellm_msgs_to_langchain_msgs(messages)}
276
- if config.get("user_prompt_prefix", ""):
277
- return {
278
- "messages": [
279
- {
280
- "role": "user",
281
- "content": config.get("user_prompt_prefix", "")
282
- + "\n\n"
283
- + user_input,
284
- }
285
- ]
286
- }
287
-
288
- return {"messages": [user_input]}
289
- elif graph_name == CUSTOM_WEB_RESEARCH_AGENT:
290
- if len(messages) > 0:
291
- return {
292
- "messages": litellm_msgs_to_langchain_msgs(messages),
293
- "model": config.get("model", "anthropic"),
294
- }
295
- return {
296
- "messages": [user_input],
297
- "model": config.get("model", "anthropic"),
298
- }
299
- elif graph_name == LANGGRAPH_REACT_AGENT:
300
- if len(messages) > 0:
301
- new_messages = []
302
- for message in messages:
303
- if message.get("role") != "system":
304
- new_messages.append(message)
305
-
306
- return {
307
- "messages": litellm_msgs_to_langchain_msgs(
308
- new_messages, enable_prompt_caching
309
- )
310
- }
311
- else:
312
- return {
313
- "messages": [user_input],
314
- }
315
- elif graph_name == DEEP_RESEARCH_AGENT:
316
- if len(messages) > 0:
317
- return {
318
- "messages": litellm_msgs_to_langchain_msgs(messages),
319
- "topic": user_input,
320
- }
321
- return {
322
- "messages": [user_input],
323
- "topic": user_input,
324
- }
325
- else:
326
- if len(messages) > 0:
327
- return {"messages": litellm_msgs_to_langchain_msgs(messages)}
328
- return {"messages": [user_input]}
329
-
330
-
331
- def get_content(graph_name: str, state: Dict[str, Any]):
332
- if graph_name == PERPLEXITY_SEARCH_AGENT:
333
- return state["messages"][-3].content
334
- elif graph_name == CUSTOM_WEB_RESEARCH_AGENT:
335
- content = state["answer"].get("markdown", "")
336
- content = content.replace("\\n", "\n")
337
- if state["answer"].get("references", []):
338
- references = "\n\n參考資料:\n"
339
- for reference in state["answer"]["references"]:
340
- references += f"- [{reference['title']}]({reference['url']})\n"
341
- content += references
342
- return content
343
- elif graph_name == DEEP_RESEARCH_AGENT:
344
- sections = state["sections"]
345
- sections_str = "\n\n".join(
346
- f"章節: {section.name}\n"
347
- f"描述: {section.description}\n"
348
- f"需要研究: {'是' if section.research else '否'}\n"
349
- for section in sections
350
- )
351
- sections_str = "預計報告結構:\n\n" + sections_str
352
- return sections_str
353
- # elif graph_name == GOV_RESEARCHER_AGENT:
354
- # return get_content_for_gov_researcher(state)
355
- elif graph_name == GOV_SUBSIDY_AGENT:
356
- messages = state["messages"]
357
- # Find the last AI message
358
- for msg in reversed(messages):
359
- if msg.type == "ai":
360
- if isinstance(msg.content, list):
361
- return msg.content[0].get("text", "")
362
- else:
363
- return msg.content
364
- return "" # If no AI message found
365
- else:
366
- messages = state["messages"]
367
- # Find the last human message
368
- last_human_idx = -1
369
- for i, msg in enumerate(messages):
370
- if msg.type == "human":
371
- last_human_idx = i
372
-
373
- # Combine all AI messages after the last human message
374
- ai_contents = ""
375
- for msg in messages[last_human_idx + 1 :]:
376
- if msg.type == "ai":
377
- if isinstance(msg.content, list):
378
- ai_contents += msg.content[0].get("text", "")
379
- else:
380
- ai_contents += msg.content
381
-
382
- return ai_contents
383
-
384
-
385
- async def process_langgraph_request(
386
- request: LangGraphRequest,
387
- retry: bool = False, # Keep retry logic for non-streaming path if needed
388
- logger: logging.Logger = default_logger,
389
- ) -> Any: # Return type can be LangGraphResponse or StreamingResponse
390
- """處理 LangGraph 請求的核心邏輯"""
391
- # --- Streaming Case ---
392
- if request.stream:
393
- logger.info(f"Processing STREAM request for graph: {request.graph_name}")
394
- # Use the new wrapper generator that handles resource management
395
- return StreamingResponse(
396
- managed_langgraph_stream_wrapper(request, logger),
397
- media_type="text/event-stream",
398
- )
399
-
400
- # --- Non-Streaming Case ---
401
- logger.info(f"Processing NON-STREAM request for graph: {request.graph_name}")
402
- try:
403
- config = request.config or {}
404
- mcp_config = config.get("mcp_config")
405
- user_id = config.get("user_id")
406
-
407
- # Get botrun_id from config
408
- botrun_id = config.get("botrun_id") # Can be None/empty
409
-
410
- # --- Graph and State Initialization (OUTSIDE of MCP client context) ---
411
- # Cache logic for LANGGRAPH_REACT_AGENT only
412
- if request.graph_name == LANGGRAPH_REACT_AGENT:
413
- graph = await get_cached_or_create_react_graph(
414
- botrun_id=botrun_id, # Pass botrun_id (can be None)
415
- config=request.config,
416
- messages=request.messages,
417
- user_input=request.user_input,
418
- logger=logger,
419
- )
420
- graph_config = request.config
421
- else:
422
- # Existing logic for other graph types (calls modified get_graph)
423
- graph, graph_config = await get_graph(
424
- request.graph_name,
425
- request.config,
426
- False, # stream=False
427
- request.messages,
428
- request.user_input,
429
- )
430
-
431
- # Determine model name for init_state caching logic if needed
432
- # user_input_model_name = request.config.get("model_name", "")
433
- # enable_caching = get_react_agent_model_name(user_input_model_name).startswith("claude-")
434
-
435
- init_state = get_init_state(
436
- request.graph_name,
437
- request.user_input,
438
- request.config,
439
- request.messages,
440
- False, # enable_prompt_caching=enable_caching
441
- )
442
-
443
- thread_id = request.thread_id if request.thread_id else str(uuid.uuid4())
444
- logger.info(f"Running non-stream with thread_id: {thread_id}")
445
-
446
- # --- Run the agent (no MCP client needed during execution) ---
447
- logger.info("Executing agent_runner for non-stream request...")
448
- async for _ in agent_runner(
449
- thread_id,
450
- init_state,
451
- graph,
452
- request.need_resume,
453
- extra_config=graph_config,
454
- ):
455
- pass # Just consume the events
456
-
457
- logger.info(
458
- f"agent_runner completed for thread_id: {thread_id}. Fetching final state."
459
- )
460
-
461
- # --- Get Final State and Prepare Response (OUTSIDE of MCP client context) ---
462
- config_for_state = {"configurable": {"thread_id": thread_id}}
463
- state = await graph.aget_state(config_for_state)
464
-
465
- try:
466
- state_values_json = langgraph_event_to_json(state.values)
467
- logger.info(
468
- f"Final state fetched for {thread_id}: {state_values_json[:500]}..."
469
- ) # Log truncated state
470
- except Exception as e_log:
471
- logger.error(f"Error serializing final state for logging: {e_log}")
472
- logger.info(
473
- f"Final state keys for {thread_id}: {list(state.values.keys())}"
474
- )
475
-
476
- content = get_content(request.graph_name, state.values)
477
-
478
- model_name_config = (
479
- request.config.get("model_name", "") if request.config else ""
480
- )
481
- final_model_name = model_name_config # Default to config model name
482
- if request.graph_name == LANGGRAPH_REACT_AGENT:
483
- final_model_name = get_react_agent_model_name(model_name_config)
484
- token_usage = extract_token_usage_from_state(state.values, final_model_name)
485
- else:
486
- token_usage = TokenUsage(
487
- total_input_tokens=0,
488
- total_output_tokens=0,
489
- total_tokens=0,
490
- nodes=[],
491
- )
492
-
493
- return LangGraphResponse(
494
- id=thread_id,
495
- created=int(time.time()),
496
- model=request.graph_name, # Or final_model_name? Check requirements
497
- content=content,
498
- state=state.values, # Consider serializing state here if needed client-side
499
- token_usage=token_usage,
500
- )
501
-
502
- except anthropic.RateLimitError as e:
503
- if retry:
504
- logger.error(
505
- "Retry failed with Anthropic RateLimitError (non-stream)", exc_info=True
506
- )
507
- raise HTTPException(
508
- status_code=429, detail=f"Rate limit exceeded after retry: {e}"
509
- ) # 429 is more appropriate
510
-
511
- logger.warning(
512
- f"Anthropic RateLimitError occurred (non-stream): {e}. Retrying..."
513
- )
514
- retry_delay = random.randint(7, 20)
515
- time.sleep(
516
- retry_delay
517
- ) # Note: time.sleep blocks async. Consider asyncio.sleep(retry_delay) if this becomes an issue.
518
- logger.info(f"Retrying non-stream request after {retry_delay}s delay...")
519
- return await process_langgraph_request(
520
- request, retry=True, logger=logger
521
- ) # Recursive call for retry
522
-
523
- except GraphRecursionError as e:
524
- logger.error(f"GraphRecursionError (non-stream): {e}", exc_info=True)
525
- raise HTTPException(
526
- status_code=500, detail=f"Graph execution exceeded maximum depth: {e}"
527
- )
528
-
529
- except Exception as e:
530
- import traceback
531
-
532
- tb_str = traceback.format_exc()
533
- logger.error(
534
- f"Unhandled exception in process_langgraph_request (non-stream): {e}",
535
- exc_info=True,
536
- )
537
- raise HTTPException(
538
- status_code=500, detail=f"Internal Server Error during graph execution: {e}"
539
- )
540
-
541
-
542
- @router.post("/invoke")
543
- async def invoke(request: LangGraphRequest):
544
- """
545
- 執行指定的 LangGraph,支援串流和非串流模式
546
-
547
- Args:
548
- request: 包含 graph_name 和輸入數據的請求
549
-
550
- Returns:
551
- 串流模式: StreamingResponse
552
- 非串流模式: LangGraphResponse
553
- """
554
- session_id = request.session_id
555
- user_id = request.config.get("user_id", "")
556
-
557
- # *** Create a session-specific BotrunLogger for this specific request ***
558
- # This ensures Cloud Logging and session/user context
559
- logger = get_session_botrun_logger(session_id=session_id, user_id=user_id)
560
-
561
- logger.info(
562
- "invoke LangGraph API",
563
- request=request.model_dump(),
564
- )
565
-
566
- # Pass the request-specific BotrunLogger down
567
- return await process_langgraph_request(request, logger=logger)
568
-
569
-
570
- # NEW: Wrapper generator for managing resources during streaming
571
- async def managed_langgraph_stream_wrapper(
572
- request: LangGraphRequest, logger: logging.Logger
573
- ):
574
- """
575
- Manages AsyncExitStack and MCPClient lifecycle for streaming responses.
576
- Initializes graph and then yields events from langgraph_stream_response_generator.
577
- """
578
- try:
579
- config = request.config or {}
580
- mcp_config = config.get("mcp_config")
581
- user_id = config.get("user_id")
582
- print(f"mcp_config: {mcp_config}, user_id: {user_id}")
583
-
584
- # Get botrun_id from config
585
- botrun_id = config.get("botrun_id") # Can be None/empty
586
-
587
- # --- Graph and State Initialization (OUTSIDE of MCP client context) ---
588
- logger.info("Getting graph and initial state for stream...")
589
- # Cache logic for LANGGRAPH_REACT_AGENT only
590
- if request.graph_name == LANGGRAPH_REACT_AGENT:
591
- graph = await get_cached_or_create_react_graph(
592
- botrun_id=botrun_id, # Pass botrun_id (can be None)
593
- config=request.config,
594
- messages=request.messages,
595
- user_input=request.user_input,
596
- logger=logger,
597
- )
598
- graph_config = request.config
599
- else:
600
- # Existing logic for other graph types (calls modified get_graph)
601
- graph, graph_config = await get_graph(
602
- request.graph_name,
603
- request.config,
604
- request.stream, # Pass stream=True
605
- request.messages,
606
- request.user_input,
607
- )
608
-
609
- # Determine model name for init_state caching logic if needed
610
- # user_input_model_name = request.config.get("model_name", "")
611
- # enable_caching = get_react_agent_model_name(user_input_model_name).startswith("claude-") # Example
612
-
613
- init_state = get_init_state(
614
- request.graph_name,
615
- request.user_input,
616
- request.config,
617
- request.messages,
618
- False, # enable_prompt_caching=enable_caching # Pass caching flag if used
619
- )
620
-
621
- thread_id = request.thread_id if request.thread_id else str(uuid.uuid4())
622
- logger.info(f"Streaming with thread_id: {thread_id}")
623
-
624
- # --- Yield from the actual stream response generator ---
625
- async for event in langgraph_stream_response_generator(
626
- thread_id,
627
- init_state,
628
- graph,
629
- request.need_resume,
630
- logger,
631
- graph_config,
632
- ):
633
- yield event # Yield the formatted event string
634
-
635
- except anthropic.RateLimitError as e:
636
- # Handle rate limit errors specifically for streaming if needed
637
- # Note: Retry logic might be complex to implement correctly within a generator.
638
- # Consider if retry should happen at a higher level or if yielding an error is sufficient.
639
- logger.error(
640
- f"Anthropic RateLimitError during stream setup/execution: {e}",
641
- exc_info=True,
642
- )
643
- error_payload = json.dumps(
644
- {"error": f"Rate Limit Error: {e}", "retry_possible": False}
645
- ) # Indicate no auto-retry here
646
- yield f"data: {error_payload}\n\n"
647
- yield "data: [DONE]\n\n" # Ensure stream terminates correctly
648
-
649
- except GraphRecursionError as e:
650
- # Handle recursion errors specifically (can happen during graph execution)
651
- logger.error(
652
- f"GraphRecursionError during stream: {e} for thread_id: {thread_id}",
653
- error=str(e),
654
- exc_info=True,
655
- )
656
- try:
657
- error_msg = json.dumps(
658
- {"error": ERR_GRAPH_RECURSION_ERROR, "detail": str(e)}
659
- )
660
- yield f"data: {error_msg}\n\n"
661
- except Exception as inner_e:
662
- logger.error(
663
- f"Error serializing GraphRecursionError for stream: {inner_e}",
664
- exc_info=True,
665
- )
666
- yield f"data: {json.dumps({'error': ERR_GRAPH_RECURSION_ERROR})}\n\n"
667
- yield "data: [DONE]\n\n" # Ensure stream terminates correctly
668
-
669
- except Exception as e:
670
- # Catch-all for other errors during setup or streaming
671
- import traceback
672
-
673
- tb_str = traceback.format_exc()
674
- logger.error(
675
- f"Unhandled exception in managed_langgraph_stream_wrapper: {e}",
676
- exc_info=True,
677
- traceback=tb_str,
678
- )
679
- error_payload = json.dumps({"error": f"Streaming Error: {e}", "detail": tb_str})
680
- yield f"data: {error_payload}\n\n"
681
- yield "data: [DONE]\n\n" # Ensure stream terminates correctly
682
-
683
-
684
- # RENAMED: Original langgraph_stream_response, now focused on generation
685
- async def langgraph_stream_response_generator(
686
- thread_id: str,
687
- init_state: Dict,
688
- graph: Any, # Receives the already configured graph
689
- need_resume: bool = False,
690
- logger: logging.Logger = default_logger,
691
- extra_config: Optional[Dict] = None,
692
- ):
693
- """
694
- Generates LangGraph stream events using langgraph_runner.
695
- Handles formatting ('data: ...') and '[DONE]' signal.
696
- Exception handling specific to langgraph_runner execution.
697
- """
698
- try:
699
- logger.info(
700
- "Starting langgraph_runner iteration",
701
- thread_id=thread_id,
702
- need_resume=need_resume,
703
- )
704
-
705
- final_event = None
706
- first_event = True # To potentially log first event differently if needed
707
- async for event in langgraph_runner(
708
- thread_id, init_state, graph, need_resume, extra_config
709
- ):
710
- final_event = event # Keep track of the last event
711
- event_json_str = langgraph_event_to_json(event) # Serialize event safely
712
- if first_event:
713
- # Optional: Different logging for the very first event chunk
714
- logger.info(
715
- f"First stream event for {thread_id}: {event_json_str[:200]}..."
716
- ) # Log truncated first event
717
- first_event = False
718
-
719
- # print statement for local debugging if needed
720
- # from datetime import datetime
721
- # print(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), event_json_str)
722
-
723
- yield f"data: {event_json_str}\n\n"
724
-
725
- # Log details about the final event if needed
726
- if final_event:
727
- logger.info(
728
- "Finished langgraph_runner iteration",
729
- thread_id=thread_id,
730
- final_event_type=final_event.get("event"),
731
- # final_event_data=langgraph_event_to_json(final_event) # Log full final event if useful
732
- )
733
- else:
734
- logger.warning(
735
- "langgraph_runner finished without yielding any events",
736
- thread_id=thread_id,
737
- )
738
-
739
- yield "data: [DONE]\n\n" # Signal end of stream
740
-
741
- # Error handling remains here as these errors occur during langgraph_runner
742
- except GraphRecursionError as e:
743
- logger.error(
744
- f"GraphRecursionError in stream generator: {e} for thread_id: {thread_id}",
745
- error=str(e),
746
- exc_info=True,
747
- )
748
- try:
749
- error_msg = json.dumps(
750
- {"error": ERR_GRAPH_RECURSION_ERROR, "detail": str(e)}
751
- )
752
- yield f"data: {error_msg}\n\n"
753
- except Exception as inner_e:
754
- logger.error(
755
- f"Error serializing GraphRecursionError msg: {inner_e}", exc_info=True
756
- )
757
- yield f"data: {json.dumps({'error': ERR_GRAPH_RECURSION_ERROR})}\n\n"
758
- # Ensure [DONE] is sent even after handled error to terminate client side
759
- yield "data: [DONE]\n\n"
760
-
761
- except Exception as e:
762
- # Catch errors specifically from langgraph_runner or event processing
763
- import traceback
764
-
765
- tb_str = traceback.format_exc()
766
- logger.error(
767
- f"Exception in stream generator: {e} for thread_id: {thread_id}",
768
- error=str(e),
769
- exc_info=True,
770
- traceback=tb_str,
771
- )
772
- error_response = {"error": f"Stream Generation Error: {e}", "detail": tb_str}
773
- yield f"data: {json.dumps(error_response)}\n\n"
774
- # Ensure [DONE] is sent even after handled error
775
- yield "data: [DONE]\n\n"
776
-
777
-
778
- @router.get("/list", response_model=SupportedGraphsResponse)
779
- async def list_supported_graphs():
780
- """
781
- 列出所有支援的 LangGraph names
782
-
783
- Returns:
784
- 包含所有支援的 graph names 的列表
785
- """
786
- return SupportedGraphsResponse(graphs=list(SUPPORTED_GRAPH.keys()))
787
-
788
-
789
- @router.post("/schema", response_model=dict)
790
- async def get_graph_schema(request: GraphSchemaRequest):
791
- """
792
- 取得指定 graph 的 schema
793
- """
794
- if request.graph_name not in SUPPORTED_GRAPH:
795
- raise HTTPException(status_code=404, detail="Graph not found")
796
- return SUPPORTED_GRAPH[request.graph_name].get_context_jsonschema()
1
+ import logging
2
+ import uuid
3
+ import json
4
+ import random
5
+ import time
6
+ import re
7
+
8
+ from fastapi import APIRouter, HTTPException
9
+
10
+ from pydantic import BaseModel
11
+
12
+ from typing import Dict, Any, List, Optional
13
+
14
+ from fastapi.responses import StreamingResponse
15
+
16
+ from botrun_flow_lang.constants import ERR_GRAPH_RECURSION_ERROR, LANG_EN, LANG_ZH_TW
17
+
18
+ from botrun_flow_lang.langgraph_agents.agents.agent_runner import (
19
+ agent_runner,
20
+ langgraph_runner,
21
+ )
22
+
23
+ from botrun_flow_lang.langgraph_agents.agents.gov_researcher.gov_researcher_graph import (
24
+ GovResearcherGraph,
25
+ get_content_for_gov_researcher,
26
+ )
27
+ from botrun_flow_lang.langgraph_agents.agents.gov_researcher.gov_researcher_2_graph import (
28
+ TAIWAN_SUBSIDY_SUPERVISOR_PROMPT,
29
+ create_taiwan_subsidy_agent_graph,
30
+ taiwan_subsidy_agent_graph,
31
+ )
32
+ from botrun_flow_lang.langgraph_agents.agents.langgraph_react_agent import (
33
+ create_react_agent_graph,
34
+ get_react_agent_model_name,
35
+ )
36
+
37
+ from botrun_flow_lang.langgraph_agents.cache.langgraph_botrun_cache import (
38
+ get_botrun_cache,
39
+ )
40
+
41
+ from botrun_flow_lang.models.token_usage import TokenUsage
42
+
43
+ from botrun_flow_lang.utils.botrun_logger import (
44
+ get_session_botrun_logger,
45
+ default_logger,
46
+ )
47
+
48
+ # 放到要用的時候才 init,不然loading 會花時間
49
+ # 因為要讓 langgraph 在本地端執行,所以這一段又搬回到外面了
50
+ from langgraph.errors import GraphRecursionError
51
+ import anthropic # Keep relevant imports if needed for error handling here
52
+
53
+ # ==========
54
+
55
+ from botrun_flow_lang.langgraph_agents.agents.search_agent_graph import (
56
+ SearchAgentGraph,
57
+ # graph as search_agent_graph,
58
+ )
59
+
60
+ from botrun_flow_lang.utils.langchain_utils import (
61
+ extract_token_usage_from_state,
62
+ langgraph_event_to_json,
63
+ litellm_msgs_to_langchain_msgs,
64
+ )
65
+
66
+
67
+ router = APIRouter(prefix="/langgraph")
68
+
69
+
70
+ class LangGraphRequest(BaseModel):
71
+ graph_name: str
72
+ # todo LangGraph 應該要傳 thread_id,但是因為現在是 cloud run 的架構,所以 thread_id 不一定會讀的到 (auto scale)
73
+ thread_id: Optional[str] = None
74
+ user_input: Optional[str] = None
75
+ messages: List[Dict[str, Any]] = []
76
+ config: Optional[Dict[str, Any]] = None
77
+ stream: bool = False
78
+ # LangGraph 是否需要從 checkpoint 恢復
79
+ need_resume: bool = False
80
+ session_id: Optional[str] = None
81
+
82
+
83
+ class LangGraphResponse(BaseModel):
84
+ """
85
+ @param content: 這個是給評測用來評估結果用的
86
+ @param state: 這個是graph的 final state,如果需要額外資訊可以使用
87
+ @param token_usage: Token usage information for the entire graph execution
88
+ """
89
+
90
+ id: str
91
+ object: str = "chat.completion"
92
+ created: int
93
+ model: str
94
+ content: Optional[str] = None
95
+ state: Optional[Dict[str, Any]] = None
96
+ token_usage: Optional[TokenUsage] = None
97
+
98
+
99
+ class SupportedGraphsResponse(BaseModel):
100
+ """Response model for listing supported graphs"""
101
+
102
+ graphs: List[str]
103
+
104
+
105
+ class GraphSchemaRequest(BaseModel):
106
+ """Request model for getting graph schema"""
107
+
108
+ graph_name: str
109
+
110
+
111
+ PERPLEXITY_SEARCH_AGENT = "perplexity_search_agent"
112
+ CUSTOM_WEB_RESEARCH_AGENT = "custom_web_research_agent"
113
+ LANGGRAPH_REACT_AGENT = "langgraph_react_agent"
114
+ DEEP_RESEARCH_AGENT = "deep_research_agent"
115
+ # GOV_RESEARCHER_AGENT = "gov_researcher_agent"
116
+ GOV_SUBSIDY_AGENT = "gov_subsidy_agent"
117
+
118
+
119
+ SUPPORTED_GRAPH_NAMES = [
120
+ # PERPLEXITY_SEARCH_AGENT,
121
+ LANGGRAPH_REACT_AGENT,
122
+ GOV_SUBSIDY_AGENT,
123
+ # GOV_RESEARCHER_AGENT,
124
+ ]
125
+ SUPPORTED_GRAPH = {
126
+ GOV_SUBSIDY_AGENT: taiwan_subsidy_agent_graph,
127
+ }
128
+
129
+
130
+ def contains_chinese_chars(text: str) -> bool:
131
+ """Check if the given text contains any Chinese characters."""
132
+ if not text:
133
+ return False
134
+ # This pattern matches Chinese characters (both simplified and traditional)
135
+ pattern = re.compile(
136
+ r"[\u4e00-\u9fff\u3400-\u4dbf\U00020000-\U0002a6df\U0002a700-\U0002ebef]"
137
+ )
138
+ return bool(pattern.search(text))
139
+
140
+
141
+ async def get_cached_or_create_react_graph(
142
+ botrun_id: Optional[str], # Key parameter - can be None/empty
143
+ config: Optional[Dict[str, Any]] = None,
144
+ messages: Optional[List[Dict]] = None,
145
+ user_input: Optional[str] = None,
146
+ logger: logging.Logger = default_logger,
147
+ ) -> Any:
148
+ """
149
+ Get cached graph for LANGGRAPH_REACT_AGENT or create new one.
150
+ Handles botrun_id-based caching with parameter validation.
151
+
152
+ Args:
153
+ botrun_id: The botrun ID for cache lookup. If None/empty, skips caching.
154
+ config: Configuration dictionary
155
+ messages: List of message dictionaries
156
+ user_input: User input string
157
+ logger: Logger instance
158
+
159
+ Returns:
160
+ Graph instance (cached or newly created)
161
+ """
162
+ config = config or {}
163
+
164
+ # Extract parameters for hash calculation (moved from get_graph)
165
+ system_prompt = config.get("system_prompt", "")
166
+ if messages:
167
+ for message in messages:
168
+ if message.get("role") == "system":
169
+ system_prompt = message.get("content", "")
170
+
171
+ botrun_flow_lang_url = config.get("botrun_flow_lang_url", "")
172
+ user_id = config.get("user_id", "")
173
+ model_name = config.get("model_name", "")
174
+
175
+ # Determine language (moved from get_graph)
176
+ has_chinese = contains_chinese_chars(system_prompt)
177
+ if not has_chinese and user_input:
178
+ has_chinese = contains_chinese_chars(user_input)
179
+ lang = LANG_ZH_TW if has_chinese else LANG_EN
180
+
181
+ mcp_config = config.get("mcp_config")
182
+
183
+ # CRITICAL: Check if botrun_id is provided and not empty
184
+ if not botrun_id:
185
+ # If botrun_id is None or empty, skip caching entirely
186
+ logger.info("No botrun_id provided, creating new graph without caching")
187
+ graph = await create_react_agent_graph(
188
+ system_prompt=system_prompt,
189
+ botrun_flow_lang_url=botrun_flow_lang_url,
190
+ user_id=user_id,
191
+ model_name=model_name,
192
+ lang=lang,
193
+ mcp_config=mcp_config,
194
+ )
195
+ return graph
196
+
197
+ # If botrun_id is provided, use caching logic
198
+ cache = get_botrun_cache()
199
+ params_hash = cache.get_params_hash(
200
+ system_prompt, botrun_flow_lang_url, user_id, model_name, lang, mcp_config
201
+ )
202
+
203
+ # Try to get cached graph
204
+ cached_graph = cache.get_cached_graph(botrun_id, params_hash)
205
+ if cached_graph:
206
+ logger.info(f"Using cached graph for botrun_id: {botrun_id}")
207
+ return cached_graph
208
+
209
+ # Create new graph (same logic as in get_graph)
210
+ logger.info(f"Creating new graph for botrun_id: {botrun_id}")
211
+ graph = await create_react_agent_graph(
212
+ system_prompt=system_prompt,
213
+ botrun_flow_lang_url=botrun_flow_lang_url,
214
+ user_id=user_id,
215
+ model_name=model_name,
216
+ lang=lang,
217
+ mcp_config=mcp_config,
218
+ )
219
+
220
+ # Cache the new graph
221
+ cache.cache_graph(botrun_id, params_hash, graph)
222
+
223
+ return graph
224
+
225
+
226
+ async def get_graph(
227
+ graph_name: str,
228
+ config: Optional[Dict[str, Any]] = None,
229
+ stream: bool = False,
230
+ messages: Optional[List[Dict]] = [],
231
+ user_input: Optional[str] = None,
232
+ ):
233
+ if (
234
+ graph_name not in SUPPORTED_GRAPH_NAMES
235
+ and graph_name not in SUPPORTED_GRAPH.keys()
236
+ ):
237
+ raise ValueError(f"Unsupported graph from get_graph: {graph_name}")
238
+ if graph_name == PERPLEXITY_SEARCH_AGENT:
239
+ graph = SearchAgentGraph().graph
240
+ graph_config = {
241
+ "search_prompt": config.get("search_prompt", ""),
242
+ "model_name": config.get("model_name", "sonar-reasoning-pro"),
243
+ "related_prompt": config.get("related_question_prompt", ""),
244
+ "search_vendor": config.get("search_vendor", "perplexity"),
245
+ "domain_filter": config.get("domain_filter", []),
246
+ "user_prompt_prefix": config.get("user_prompt_prefix", ""),
247
+ "stream": stream,
248
+ }
249
+ elif graph_name == GOV_SUBSIDY_AGENT:
250
+ graph = create_taiwan_subsidy_agent_graph(
251
+ config.get("prompt_template", TAIWAN_SUBSIDY_SUPERVISOR_PROMPT)
252
+ )
253
+ graph_config = {
254
+ "prompt_template": config.get("prompt_template", ""),
255
+ "legal_extraction_prompt": config.get("legal_extraction_prompt", ""),
256
+ "faq_extraction_prompt": config.get("faq_extraction_prompt", ""),
257
+ "calculation_analysis_prompt": config.get(
258
+ "calculation_analysis_prompt", ""
259
+ ),
260
+ }
261
+ else:
262
+ raise ValueError(f"Unsupported graph type: {graph_name}")
263
+ return graph, graph_config
264
+
265
+
266
+ def get_init_state(
267
+ graph_name: str,
268
+ user_input: str,
269
+ config: Optional[Dict[str, Any]] = None,
270
+ messages: Optional[List[Dict]] = [],
271
+ enable_prompt_caching: bool = False,
272
+ ):
273
+ if graph_name == PERPLEXITY_SEARCH_AGENT:
274
+ if len(messages) > 0:
275
+ return {"messages": litellm_msgs_to_langchain_msgs(messages)}
276
+ if config.get("user_prompt_prefix", ""):
277
+ return {
278
+ "messages": [
279
+ {
280
+ "role": "user",
281
+ "content": config.get("user_prompt_prefix", "")
282
+ + "\n\n"
283
+ + user_input,
284
+ }
285
+ ]
286
+ }
287
+
288
+ return {"messages": [user_input]}
289
+ elif graph_name == CUSTOM_WEB_RESEARCH_AGENT:
290
+ if len(messages) > 0:
291
+ return {
292
+ "messages": litellm_msgs_to_langchain_msgs(messages),
293
+ "model": config.get("model", "anthropic"),
294
+ }
295
+ return {
296
+ "messages": [user_input],
297
+ "model": config.get("model", "anthropic"),
298
+ }
299
+ elif graph_name == LANGGRAPH_REACT_AGENT:
300
+ if len(messages) > 0:
301
+ new_messages = []
302
+ for message in messages:
303
+ if message.get("role") != "system":
304
+ new_messages.append(message)
305
+
306
+ return {
307
+ "messages": litellm_msgs_to_langchain_msgs(
308
+ new_messages, enable_prompt_caching
309
+ )
310
+ }
311
+ else:
312
+ return {
313
+ "messages": [user_input],
314
+ }
315
+ elif graph_name == DEEP_RESEARCH_AGENT:
316
+ if len(messages) > 0:
317
+ return {
318
+ "messages": litellm_msgs_to_langchain_msgs(messages),
319
+ "topic": user_input,
320
+ }
321
+ return {
322
+ "messages": [user_input],
323
+ "topic": user_input,
324
+ }
325
+ else:
326
+ if len(messages) > 0:
327
+ return {"messages": litellm_msgs_to_langchain_msgs(messages)}
328
+ return {"messages": [user_input]}
329
+
330
+
331
+ def get_content(graph_name: str, state: Dict[str, Any]):
332
+ if graph_name == PERPLEXITY_SEARCH_AGENT:
333
+ return state["messages"][-3].content
334
+ elif graph_name == CUSTOM_WEB_RESEARCH_AGENT:
335
+ content = state["answer"].get("markdown", "")
336
+ content = content.replace("\\n", "\n")
337
+ if state["answer"].get("references", []):
338
+ references = "\n\n參考資料:\n"
339
+ for reference in state["answer"]["references"]:
340
+ references += f"- [{reference['title']}]({reference['url']})\n"
341
+ content += references
342
+ return content
343
+ elif graph_name == DEEP_RESEARCH_AGENT:
344
+ sections = state["sections"]
345
+ sections_str = "\n\n".join(
346
+ f"章節: {section.name}\n"
347
+ f"描述: {section.description}\n"
348
+ f"需要研究: {'是' if section.research else '否'}\n"
349
+ for section in sections
350
+ )
351
+ sections_str = "預計報告結構:\n\n" + sections_str
352
+ return sections_str
353
+ # elif graph_name == GOV_RESEARCHER_AGENT:
354
+ # return get_content_for_gov_researcher(state)
355
+ elif graph_name == GOV_SUBSIDY_AGENT:
356
+ messages = state["messages"]
357
+ # Find the last AI message
358
+ for msg in reversed(messages):
359
+ if msg.type == "ai":
360
+ if isinstance(msg.content, list):
361
+ return msg.content[0].get("text", "")
362
+ else:
363
+ return msg.content
364
+ return "" # If no AI message found
365
+ else:
366
+ messages = state["messages"]
367
+ # Find the last human message
368
+ last_human_idx = -1
369
+ for i, msg in enumerate(messages):
370
+ if msg.type == "human":
371
+ last_human_idx = i
372
+
373
+ # Combine all AI messages after the last human message
374
+ ai_contents = ""
375
+ for msg in messages[last_human_idx + 1 :]:
376
+ if msg.type == "ai":
377
+ if isinstance(msg.content, list):
378
+ ai_contents += msg.content[0].get("text", "")
379
+ else:
380
+ ai_contents += msg.content
381
+
382
+ return ai_contents
383
+
384
+
385
+ async def process_langgraph_request(
386
+ request: LangGraphRequest,
387
+ retry: bool = False, # Keep retry logic for non-streaming path if needed
388
+ logger: logging.Logger = default_logger,
389
+ ) -> Any: # Return type can be LangGraphResponse or StreamingResponse
390
+ """處理 LangGraph 請求的核心邏輯"""
391
+ # --- Streaming Case ---
392
+ if request.stream:
393
+ logger.info(f"Processing STREAM request for graph: {request.graph_name}")
394
+ # Use the new wrapper generator that handles resource management
395
+ return StreamingResponse(
396
+ managed_langgraph_stream_wrapper(request, logger),
397
+ media_type="text/event-stream",
398
+ )
399
+
400
+ # --- Non-Streaming Case ---
401
+ logger.info(f"Processing NON-STREAM request for graph: {request.graph_name}")
402
+ try:
403
+ config = request.config or {}
404
+ mcp_config = config.get("mcp_config")
405
+ user_id = config.get("user_id")
406
+
407
+ # Get botrun_id from config
408
+ botrun_id = config.get("botrun_id") # Can be None/empty
409
+
410
+ # --- Graph and State Initialization (OUTSIDE of MCP client context) ---
411
+ # Cache logic for LANGGRAPH_REACT_AGENT only
412
+ if request.graph_name == LANGGRAPH_REACT_AGENT:
413
+ graph = await get_cached_or_create_react_graph(
414
+ botrun_id=botrun_id, # Pass botrun_id (can be None)
415
+ config=request.config,
416
+ messages=request.messages,
417
+ user_input=request.user_input,
418
+ logger=logger,
419
+ )
420
+ graph_config = request.config
421
+ else:
422
+ # Existing logic for other graph types (calls modified get_graph)
423
+ graph, graph_config = await get_graph(
424
+ request.graph_name,
425
+ request.config,
426
+ False, # stream=False
427
+ request.messages,
428
+ request.user_input,
429
+ )
430
+
431
+ # Determine model name for init_state caching logic if needed
432
+ # user_input_model_name = request.config.get("model_name", "")
433
+ # enable_caching = get_react_agent_model_name(user_input_model_name).startswith("claude-")
434
+
435
+ init_state = get_init_state(
436
+ request.graph_name,
437
+ request.user_input,
438
+ request.config,
439
+ request.messages,
440
+ False, # enable_prompt_caching=enable_caching
441
+ )
442
+
443
+ thread_id = request.thread_id if request.thread_id else str(uuid.uuid4())
444
+ logger.info(f"Running non-stream with thread_id: {thread_id}")
445
+
446
+ # --- Run the agent (no MCP client needed during execution) ---
447
+ logger.info("Executing agent_runner for non-stream request...")
448
+ async for _ in agent_runner(
449
+ thread_id,
450
+ init_state,
451
+ graph,
452
+ request.need_resume,
453
+ extra_config=graph_config,
454
+ ):
455
+ pass # Just consume the events
456
+
457
+ logger.info(
458
+ f"agent_runner completed for thread_id: {thread_id}. Fetching final state."
459
+ )
460
+
461
+ # --- Get Final State and Prepare Response (OUTSIDE of MCP client context) ---
462
+ config_for_state = {"configurable": {"thread_id": thread_id}}
463
+ state = await graph.aget_state(config_for_state)
464
+
465
+ try:
466
+ state_values_json = langgraph_event_to_json(state.values)
467
+ logger.info(
468
+ f"Final state fetched for {thread_id}: {state_values_json[:500]}..."
469
+ ) # Log truncated state
470
+ except Exception as e_log:
471
+ logger.error(f"Error serializing final state for logging: {e_log}")
472
+ logger.info(
473
+ f"Final state keys for {thread_id}: {list(state.values.keys())}"
474
+ )
475
+
476
+ content = get_content(request.graph_name, state.values)
477
+
478
+ model_name_config = (
479
+ request.config.get("model_name", "") if request.config else ""
480
+ )
481
+ final_model_name = model_name_config # Default to config model name
482
+ if request.graph_name == LANGGRAPH_REACT_AGENT:
483
+ final_model_name = get_react_agent_model_name(model_name_config)
484
+ token_usage = extract_token_usage_from_state(state.values, final_model_name)
485
+ else:
486
+ token_usage = TokenUsage(
487
+ total_input_tokens=0,
488
+ total_output_tokens=0,
489
+ total_tokens=0,
490
+ nodes=[],
491
+ )
492
+
493
+ return LangGraphResponse(
494
+ id=thread_id,
495
+ created=int(time.time()),
496
+ model=request.graph_name, # Or final_model_name? Check requirements
497
+ content=content,
498
+ state=state.values, # Consider serializing state here if needed client-side
499
+ token_usage=token_usage,
500
+ )
501
+
502
+ except anthropic.RateLimitError as e:
503
+ if retry:
504
+ logger.error(
505
+ "Retry failed with Anthropic RateLimitError (non-stream)", exc_info=True
506
+ )
507
+ raise HTTPException(
508
+ status_code=429, detail=f"Rate limit exceeded after retry: {e}"
509
+ ) # 429 is more appropriate
510
+
511
+ logger.warning(
512
+ f"Anthropic RateLimitError occurred (non-stream): {e}. Retrying..."
513
+ )
514
+ retry_delay = random.randint(7, 20)
515
+ time.sleep(
516
+ retry_delay
517
+ ) # Note: time.sleep blocks async. Consider asyncio.sleep(retry_delay) if this becomes an issue.
518
+ logger.info(f"Retrying non-stream request after {retry_delay}s delay...")
519
+ return await process_langgraph_request(
520
+ request, retry=True, logger=logger
521
+ ) # Recursive call for retry
522
+
523
+ except GraphRecursionError as e:
524
+ logger.error(f"GraphRecursionError (non-stream): {e}", exc_info=True)
525
+ raise HTTPException(
526
+ status_code=500, detail=f"Graph execution exceeded maximum depth: {e}"
527
+ )
528
+
529
+ except Exception as e:
530
+ import traceback
531
+
532
+ tb_str = traceback.format_exc()
533
+ logger.error(
534
+ f"Unhandled exception in process_langgraph_request (non-stream): {e}",
535
+ exc_info=True,
536
+ )
537
+ raise HTTPException(
538
+ status_code=500, detail=f"Internal Server Error during graph execution: {e}"
539
+ )
540
+
541
+
542
+ @router.post("/invoke")
543
+ async def invoke(request: LangGraphRequest):
544
+ """
545
+ 執行指定的 LangGraph,支援串流和非串流模式
546
+
547
+ Args:
548
+ request: 包含 graph_name 和輸入數據的請求
549
+
550
+ Returns:
551
+ 串流模式: StreamingResponse
552
+ 非串流模式: LangGraphResponse
553
+ """
554
+ session_id = request.session_id
555
+ user_id = request.config.get("user_id", "")
556
+
557
+ # *** Create a session-specific BotrunLogger for this specific request ***
558
+ # This ensures Cloud Logging and session/user context
559
+ logger = get_session_botrun_logger(session_id=session_id, user_id=user_id)
560
+
561
+ logger.info(
562
+ "invoke LangGraph API",
563
+ request=request.model_dump(),
564
+ )
565
+
566
+ # Pass the request-specific BotrunLogger down
567
+ return await process_langgraph_request(request, logger=logger)
568
+
569
+
570
+ # NEW: Wrapper generator for managing resources during streaming
571
+ async def managed_langgraph_stream_wrapper(
572
+ request: LangGraphRequest, logger: logging.Logger
573
+ ):
574
+ """
575
+ Manages AsyncExitStack and MCPClient lifecycle for streaming responses.
576
+ Initializes graph and then yields events from langgraph_stream_response_generator.
577
+ """
578
+ try:
579
+ config = request.config or {}
580
+ mcp_config = config.get("mcp_config")
581
+ user_id = config.get("user_id")
582
+ print(f"mcp_config: {mcp_config}, user_id: {user_id}")
583
+
584
+ # Get botrun_id from config
585
+ botrun_id = config.get("botrun_id") # Can be None/empty
586
+
587
+ # --- Graph and State Initialization (OUTSIDE of MCP client context) ---
588
+ logger.info("Getting graph and initial state for stream...")
589
+ # Cache logic for LANGGRAPH_REACT_AGENT only
590
+ if request.graph_name == LANGGRAPH_REACT_AGENT:
591
+ graph = await get_cached_or_create_react_graph(
592
+ botrun_id=botrun_id, # Pass botrun_id (can be None)
593
+ config=request.config,
594
+ messages=request.messages,
595
+ user_input=request.user_input,
596
+ logger=logger,
597
+ )
598
+ graph_config = request.config
599
+ else:
600
+ # Existing logic for other graph types (calls modified get_graph)
601
+ graph, graph_config = await get_graph(
602
+ request.graph_name,
603
+ request.config,
604
+ request.stream, # Pass stream=True
605
+ request.messages,
606
+ request.user_input,
607
+ )
608
+
609
+ # Determine model name for init_state caching logic if needed
610
+ # user_input_model_name = request.config.get("model_name", "")
611
+ # enable_caching = get_react_agent_model_name(user_input_model_name).startswith("claude-") # Example
612
+
613
+ init_state = get_init_state(
614
+ request.graph_name,
615
+ request.user_input,
616
+ request.config,
617
+ request.messages,
618
+ False, # enable_prompt_caching=enable_caching # Pass caching flag if used
619
+ )
620
+
621
+ thread_id = request.thread_id if request.thread_id else str(uuid.uuid4())
622
+ logger.info(f"Streaming with thread_id: {thread_id}")
623
+
624
+ # --- Yield from the actual stream response generator ---
625
+ async for event in langgraph_stream_response_generator(
626
+ thread_id,
627
+ init_state,
628
+ graph,
629
+ request.need_resume,
630
+ logger,
631
+ graph_config,
632
+ ):
633
+ yield event # Yield the formatted event string
634
+
635
+ except anthropic.RateLimitError as e:
636
+ # Handle rate limit errors specifically for streaming if needed
637
+ # Note: Retry logic might be complex to implement correctly within a generator.
638
+ # Consider if retry should happen at a higher level or if yielding an error is sufficient.
639
+ logger.error(
640
+ f"Anthropic RateLimitError during stream setup/execution: {e}",
641
+ exc_info=True,
642
+ )
643
+ error_payload = json.dumps(
644
+ {"error": f"Rate Limit Error: {e}", "retry_possible": False}
645
+ ) # Indicate no auto-retry here
646
+ yield f"data: {error_payload}\n\n"
647
+ yield "data: [DONE]\n\n" # Ensure stream terminates correctly
648
+
649
+ except GraphRecursionError as e:
650
+ # Handle recursion errors specifically (can happen during graph execution)
651
+ logger.error(
652
+ f"GraphRecursionError during stream: {e} for thread_id: {thread_id}",
653
+ error=str(e),
654
+ exc_info=True,
655
+ )
656
+ try:
657
+ error_msg = json.dumps(
658
+ {"error": ERR_GRAPH_RECURSION_ERROR, "detail": str(e)}
659
+ )
660
+ yield f"data: {error_msg}\n\n"
661
+ except Exception as inner_e:
662
+ logger.error(
663
+ f"Error serializing GraphRecursionError for stream: {inner_e}",
664
+ exc_info=True,
665
+ )
666
+ yield f"data: {json.dumps({'error': ERR_GRAPH_RECURSION_ERROR})}\n\n"
667
+ yield "data: [DONE]\n\n" # Ensure stream terminates correctly
668
+
669
+ except Exception as e:
670
+ # Catch-all for other errors during setup or streaming
671
+ import traceback
672
+
673
+ tb_str = traceback.format_exc()
674
+ logger.error(
675
+ f"Unhandled exception in managed_langgraph_stream_wrapper: {e}",
676
+ exc_info=True,
677
+ traceback=tb_str,
678
+ )
679
+ error_payload = json.dumps({"error": f"Streaming Error: {e}", "detail": tb_str})
680
+ yield f"data: {error_payload}\n\n"
681
+ yield "data: [DONE]\n\n" # Ensure stream terminates correctly
682
+
683
+
684
+ # RENAMED: Original langgraph_stream_response, now focused on generation
685
+ async def langgraph_stream_response_generator(
686
+ thread_id: str,
687
+ init_state: Dict,
688
+ graph: Any, # Receives the already configured graph
689
+ need_resume: bool = False,
690
+ logger: logging.Logger = default_logger,
691
+ extra_config: Optional[Dict] = None,
692
+ ):
693
+ """
694
+ Generates LangGraph stream events using langgraph_runner.
695
+ Handles formatting ('data: ...') and '[DONE]' signal.
696
+ Exception handling specific to langgraph_runner execution.
697
+ """
698
+ try:
699
+ logger.info(
700
+ "Starting langgraph_runner iteration",
701
+ thread_id=thread_id,
702
+ need_resume=need_resume,
703
+ )
704
+
705
+ final_event = None
706
+ first_event = True # To potentially log first event differently if needed
707
+ async for event in langgraph_runner(
708
+ thread_id, init_state, graph, need_resume, extra_config
709
+ ):
710
+ final_event = event # Keep track of the last event
711
+ event_json_str = langgraph_event_to_json(event) # Serialize event safely
712
+ if first_event:
713
+ # Optional: Different logging for the very first event chunk
714
+ logger.info(
715
+ f"First stream event for {thread_id}: {event_json_str[:200]}..."
716
+ ) # Log truncated first event
717
+ first_event = False
718
+
719
+ # print statement for local debugging if needed
720
+ # from datetime import datetime
721
+ # print(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), event_json_str)
722
+
723
+ yield f"data: {event_json_str}\n\n"
724
+
725
+ # Log details about the final event if needed
726
+ if final_event:
727
+ logger.info(
728
+ "Finished langgraph_runner iteration",
729
+ thread_id=thread_id,
730
+ final_event_type=final_event.get("event"),
731
+ # final_event_data=langgraph_event_to_json(final_event) # Log full final event if useful
732
+ )
733
+ else:
734
+ logger.warning(
735
+ "langgraph_runner finished without yielding any events",
736
+ thread_id=thread_id,
737
+ )
738
+
739
+ yield "data: [DONE]\n\n" # Signal end of stream
740
+
741
+ # Error handling remains here as these errors occur during langgraph_runner
742
+ except GraphRecursionError as e:
743
+ logger.error(
744
+ f"GraphRecursionError in stream generator: {e} for thread_id: {thread_id}",
745
+ error=str(e),
746
+ exc_info=True,
747
+ )
748
+ try:
749
+ error_msg = json.dumps(
750
+ {"error": ERR_GRAPH_RECURSION_ERROR, "detail": str(e)}
751
+ )
752
+ yield f"data: {error_msg}\n\n"
753
+ except Exception as inner_e:
754
+ logger.error(
755
+ f"Error serializing GraphRecursionError msg: {inner_e}", exc_info=True
756
+ )
757
+ yield f"data: {json.dumps({'error': ERR_GRAPH_RECURSION_ERROR})}\n\n"
758
+ # Ensure [DONE] is sent even after handled error to terminate client side
759
+ yield "data: [DONE]\n\n"
760
+
761
+ except Exception as e:
762
+ # Catch errors specifically from langgraph_runner or event processing
763
+ import traceback
764
+
765
+ tb_str = traceback.format_exc()
766
+ logger.error(
767
+ f"Exception in stream generator: {e} for thread_id: {thread_id}",
768
+ error=str(e),
769
+ exc_info=True,
770
+ traceback=tb_str,
771
+ )
772
+ error_response = {"error": f"Stream Generation Error: {e}", "detail": tb_str}
773
+ yield f"data: {json.dumps(error_response)}\n\n"
774
+ # Ensure [DONE] is sent even after handled error
775
+ yield "data: [DONE]\n\n"
776
+
777
+
778
+ @router.get("/list", response_model=SupportedGraphsResponse)
779
+ async def list_supported_graphs():
780
+ """
781
+ 列出所有支援的 LangGraph names
782
+
783
+ Returns:
784
+ 包含所有支援的 graph names 的列表
785
+ """
786
+ return SupportedGraphsResponse(graphs=list(SUPPORTED_GRAPH.keys()))
787
+
788
+
789
+ @router.post("/schema", response_model=dict)
790
+ async def get_graph_schema(request: GraphSchemaRequest):
791
+ """
792
+ 取得指定 graph 的 schema
793
+ """
794
+ if request.graph_name not in SUPPORTED_GRAPH:
795
+ raise HTTPException(status_code=404, detail="Graph not found")
796
+ return SUPPORTED_GRAPH[request.graph_name].get_context_jsonschema()