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